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/llm_retry.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""LLM chat timeout, heartbeat, and retry helpers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import os
|
|
6
|
+
import math
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from agent.providers.base import ChatResponse, LLMProvider
|
|
13
|
+
from agent.streaming import ProviderStreamCallback, StreamEvent, StreamEventCallback
|
|
14
|
+
from agent.logger import get_logger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
DEFAULT_LLM_TIMEOUT_SECONDS = 3600.0 # 1 hour for very long tasks
|
|
21
|
+
DEFAULT_LLM_MAX_RETRIES = 10 # More retries for reliability
|
|
22
|
+
DEFAULT_LLM_HEARTBEAT_SECONDS = 15.0 # Reduce noise but still visible
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _estimate_context_tokens(messages: list[dict], system_prompt: Optional[str] = None) -> int:
|
|
26
|
+
"""Estimate token count using a lightweight heuristic (1 token ≈ 4 chars)."""
|
|
27
|
+
total_chars = 0
|
|
28
|
+
if system_prompt:
|
|
29
|
+
total_chars += len(system_prompt)
|
|
30
|
+
for msg in messages:
|
|
31
|
+
content = msg.get("content", "")
|
|
32
|
+
if isinstance(content, str):
|
|
33
|
+
total_chars += len(content)
|
|
34
|
+
elif isinstance(content, list):
|
|
35
|
+
for item in content:
|
|
36
|
+
total_chars += len(str(item))
|
|
37
|
+
if msg.get("role"):
|
|
38
|
+
total_chars += len(msg["role"])
|
|
39
|
+
return math.ceil(total_chars / 4) if total_chars > 0 else 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class LLMCallError(Exception):
|
|
44
|
+
"""Raised when an LLM call fails after retries."""
|
|
45
|
+
|
|
46
|
+
message: str
|
|
47
|
+
attempts: int
|
|
48
|
+
timeout_seconds: float
|
|
49
|
+
last_error: str
|
|
50
|
+
|
|
51
|
+
def __str__(self) -> str:
|
|
52
|
+
return self.message
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def chat_with_retry(
|
|
56
|
+
provider: LLMProvider,
|
|
57
|
+
*,
|
|
58
|
+
messages: list[dict],
|
|
59
|
+
tools: list[dict],
|
|
60
|
+
system_prompt: Optional[str] = None,
|
|
61
|
+
stream_callback: Optional[ProviderStreamCallback] = None,
|
|
62
|
+
event_callback: Optional[StreamEventCallback] = None,
|
|
63
|
+
source: str = "main",
|
|
64
|
+
session_id: str = "",
|
|
65
|
+
role: Optional[str] = None,
|
|
66
|
+
parent_session_id: Optional[str] = None,
|
|
67
|
+
timeout_seconds: Optional[float] = None,
|
|
68
|
+
max_retries: Optional[int] = None,
|
|
69
|
+
heartbeat_seconds: Optional[float] = None,
|
|
70
|
+
) -> ChatResponse:
|
|
71
|
+
"""Call a provider with bounded waiting, visible heartbeat, and retry."""
|
|
72
|
+
timeout_seconds = _resolve_float_env(
|
|
73
|
+
"YOYO_LLM_TIMEOUT_SECONDS",
|
|
74
|
+
timeout_seconds,
|
|
75
|
+
DEFAULT_LLM_TIMEOUT_SECONDS,
|
|
76
|
+
)
|
|
77
|
+
heartbeat_seconds = _resolve_float_env(
|
|
78
|
+
"YOYO_LLM_HEARTBEAT_SECONDS",
|
|
79
|
+
heartbeat_seconds,
|
|
80
|
+
DEFAULT_LLM_HEARTBEAT_SECONDS,
|
|
81
|
+
)
|
|
82
|
+
max_retries = _resolve_int_env(
|
|
83
|
+
"YOYO_LLM_MAX_RETRIES",
|
|
84
|
+
max_retries,
|
|
85
|
+
DEFAULT_LLM_MAX_RETRIES,
|
|
86
|
+
)
|
|
87
|
+
attempts = max_retries + 1
|
|
88
|
+
estimated_tokens = _estimate_context_tokens(messages, system_prompt)
|
|
89
|
+
provider_name = provider.__class__.__name__
|
|
90
|
+
model_name = str(getattr(provider, "model", "(unknown)"))
|
|
91
|
+
request_id = f"{source}:{session_id or '-'}:{int(time.time() * 1000) % 1_000_000}"
|
|
92
|
+
|
|
93
|
+
logger.debug(
|
|
94
|
+
"LLM request start request_id=%s source=%s role=%s provider=%s model=%s "
|
|
95
|
+
"messages=%d tools=%d context_est_tokens=%d stream=%s timeout_s=%.3f "
|
|
96
|
+
"heartbeat_s=%.3f max_retries=%d attempts=%d",
|
|
97
|
+
request_id,
|
|
98
|
+
source,
|
|
99
|
+
role or "",
|
|
100
|
+
provider_name,
|
|
101
|
+
model_name,
|
|
102
|
+
len(messages),
|
|
103
|
+
len(tools),
|
|
104
|
+
estimated_tokens,
|
|
105
|
+
stream_callback is not None,
|
|
106
|
+
timeout_seconds,
|
|
107
|
+
heartbeat_seconds,
|
|
108
|
+
max_retries,
|
|
109
|
+
attempts,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Debug log to confirm actual values
|
|
113
|
+
if _debug_enabled() and event_callback:
|
|
114
|
+
await _emit_llm_event(
|
|
115
|
+
event_callback,
|
|
116
|
+
source=source,
|
|
117
|
+
session_id=session_id,
|
|
118
|
+
role=role,
|
|
119
|
+
parent_session_id=parent_session_id,
|
|
120
|
+
event_type="llm_waiting",
|
|
121
|
+
content=f"[debug] Starting request, context≈{estimated_tokens} tokens, timeout={timeout_seconds:.0f}s, max_retries={max_retries}, total_attempts={attempts}",
|
|
122
|
+
)
|
|
123
|
+
last_error = ""
|
|
124
|
+
|
|
125
|
+
for attempt in range(1, attempts + 1):
|
|
126
|
+
try:
|
|
127
|
+
response = await _chat_once_with_heartbeat(
|
|
128
|
+
provider,
|
|
129
|
+
messages=messages,
|
|
130
|
+
tools=tools,
|
|
131
|
+
system_prompt=system_prompt,
|
|
132
|
+
stream_callback=stream_callback,
|
|
133
|
+
event_callback=event_callback,
|
|
134
|
+
source=source,
|
|
135
|
+
session_id=session_id,
|
|
136
|
+
role=role,
|
|
137
|
+
parent_session_id=parent_session_id,
|
|
138
|
+
timeout_seconds=timeout_seconds,
|
|
139
|
+
heartbeat_seconds=heartbeat_seconds,
|
|
140
|
+
attempt=attempt,
|
|
141
|
+
attempts=attempts,
|
|
142
|
+
request_id=request_id,
|
|
143
|
+
)
|
|
144
|
+
logger.debug(
|
|
145
|
+
"LLM request success request_id=%s attempt=%d/%d",
|
|
146
|
+
request_id,
|
|
147
|
+
attempt,
|
|
148
|
+
attempts,
|
|
149
|
+
)
|
|
150
|
+
return response
|
|
151
|
+
except asyncio.TimeoutError:
|
|
152
|
+
last_error = f"Timeout after {timeout_seconds:g}s"
|
|
153
|
+
logger.warning(
|
|
154
|
+
"LLM request timeout request_id=%s attempt=%d/%d timeout_s=%.3f",
|
|
155
|
+
request_id,
|
|
156
|
+
attempt,
|
|
157
|
+
attempts,
|
|
158
|
+
timeout_seconds,
|
|
159
|
+
)
|
|
160
|
+
await _emit_llm_event(
|
|
161
|
+
event_callback,
|
|
162
|
+
source=source,
|
|
163
|
+
session_id=session_id,
|
|
164
|
+
role=role,
|
|
165
|
+
parent_session_id=parent_session_id,
|
|
166
|
+
event_type="llm_timeout",
|
|
167
|
+
content=f"{last_error} (attempt {attempt}/{attempts})",
|
|
168
|
+
title="Model request timed out",
|
|
169
|
+
detail=f"Attempt {attempt}/{attempts}",
|
|
170
|
+
phase="waiting",
|
|
171
|
+
status="timeout",
|
|
172
|
+
metadata={"attempt": attempt, "attempts": attempts},
|
|
173
|
+
)
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
last_error = str(exc) or exc.__class__.__name__
|
|
176
|
+
logger.warning(
|
|
177
|
+
"LLM request error request_id=%s attempt=%d/%d error_type=%s error=%s",
|
|
178
|
+
request_id,
|
|
179
|
+
attempt,
|
|
180
|
+
attempts,
|
|
181
|
+
exc.__class__.__name__,
|
|
182
|
+
last_error,
|
|
183
|
+
)
|
|
184
|
+
await _emit_llm_event(
|
|
185
|
+
event_callback,
|
|
186
|
+
source=source,
|
|
187
|
+
session_id=session_id,
|
|
188
|
+
role=role,
|
|
189
|
+
parent_session_id=parent_session_id,
|
|
190
|
+
event_type="llm_error",
|
|
191
|
+
content=f"{last_error} (attempt {attempt}/{attempts})",
|
|
192
|
+
title="Model request failed",
|
|
193
|
+
detail=f"Attempt {attempt}/{attempts}",
|
|
194
|
+
phase="waiting",
|
|
195
|
+
status="failed",
|
|
196
|
+
metadata={"attempt": attempt, "attempts": attempts},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if attempt < attempts:
|
|
200
|
+
logger.debug(
|
|
201
|
+
"LLM request retry scheduled request_id=%s next_attempt=%d/%d",
|
|
202
|
+
request_id,
|
|
203
|
+
attempt + 1,
|
|
204
|
+
attempts,
|
|
205
|
+
)
|
|
206
|
+
await _emit_llm_event(
|
|
207
|
+
event_callback,
|
|
208
|
+
source=source,
|
|
209
|
+
session_id=session_id,
|
|
210
|
+
role=role,
|
|
211
|
+
parent_session_id=parent_session_id,
|
|
212
|
+
event_type="llm_retry",
|
|
213
|
+
content=f"retrying model request ({attempt + 1}/{attempts})",
|
|
214
|
+
title="Retrying model request",
|
|
215
|
+
detail=f"Attempt {attempt + 1}/{attempts}",
|
|
216
|
+
phase="waiting",
|
|
217
|
+
status="retrying",
|
|
218
|
+
metadata={"attempt": attempt + 1, "attempts": attempts},
|
|
219
|
+
)
|
|
220
|
+
await asyncio.sleep(min(2.0, 0.5 * attempt))
|
|
221
|
+
|
|
222
|
+
raise LLMCallError(
|
|
223
|
+
message=f"Model request failed after {attempts} attempt(s): {last_error}",
|
|
224
|
+
attempts=attempts,
|
|
225
|
+
timeout_seconds=timeout_seconds,
|
|
226
|
+
last_error=last_error,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def _chat_once_with_heartbeat(
|
|
231
|
+
provider: LLMProvider,
|
|
232
|
+
*,
|
|
233
|
+
messages: list[dict],
|
|
234
|
+
tools: list[dict],
|
|
235
|
+
system_prompt: Optional[str],
|
|
236
|
+
stream_callback: Optional[ProviderStreamCallback],
|
|
237
|
+
event_callback: Optional[StreamEventCallback],
|
|
238
|
+
source: str,
|
|
239
|
+
session_id: str,
|
|
240
|
+
role: Optional[str],
|
|
241
|
+
parent_session_id: Optional[str],
|
|
242
|
+
timeout_seconds: float,
|
|
243
|
+
heartbeat_seconds: float,
|
|
244
|
+
attempt: int,
|
|
245
|
+
attempts: int,
|
|
246
|
+
request_id: str,
|
|
247
|
+
) -> ChatResponse:
|
|
248
|
+
last_activity = time.monotonic()
|
|
249
|
+
first_stream_event_at: Optional[float] = None
|
|
250
|
+
stream_event_count = 0
|
|
251
|
+
|
|
252
|
+
async def activity_stream_callback(event_type: str, content: str) -> None:
|
|
253
|
+
nonlocal first_stream_event_at, last_activity, stream_event_count
|
|
254
|
+
now = time.monotonic()
|
|
255
|
+
if first_stream_event_at is None:
|
|
256
|
+
first_stream_event_at = now
|
|
257
|
+
logger.debug(
|
|
258
|
+
"LLM first stream event request_id=%s attempt=%d/%d event_type=%s "
|
|
259
|
+
"first_token_latency_s=%.3f",
|
|
260
|
+
request_id,
|
|
261
|
+
attempt,
|
|
262
|
+
attempts,
|
|
263
|
+
event_type,
|
|
264
|
+
now - start,
|
|
265
|
+
)
|
|
266
|
+
stream_event_count += 1
|
|
267
|
+
last_activity = time.monotonic()
|
|
268
|
+
if stream_callback is not None:
|
|
269
|
+
await stream_callback(event_type, content)
|
|
270
|
+
|
|
271
|
+
# Update activity before starting to avoid idle_seconds jumping
|
|
272
|
+
last_activity = time.monotonic()
|
|
273
|
+
start = time.monotonic()
|
|
274
|
+
|
|
275
|
+
task = asyncio.create_task(
|
|
276
|
+
provider.chat(
|
|
277
|
+
messages=messages,
|
|
278
|
+
tools=tools,
|
|
279
|
+
system_prompt=system_prompt,
|
|
280
|
+
stream_callback=activity_stream_callback if stream_callback else None,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
logger.debug(
|
|
284
|
+
"LLM attempt started request_id=%s attempt=%d/%d timeout_s=%.3f",
|
|
285
|
+
request_id,
|
|
286
|
+
attempt,
|
|
287
|
+
attempts,
|
|
288
|
+
timeout_seconds,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
while True:
|
|
293
|
+
remaining = timeout_seconds - (time.monotonic() - start)
|
|
294
|
+
if remaining <= 0:
|
|
295
|
+
task.cancel()
|
|
296
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
297
|
+
await task
|
|
298
|
+
raise asyncio.TimeoutError()
|
|
299
|
+
|
|
300
|
+
done, _ = await asyncio.wait({task}, timeout=min(heartbeat_seconds, remaining))
|
|
301
|
+
if done:
|
|
302
|
+
response = await task
|
|
303
|
+
elapsed_seconds = time.monotonic() - start
|
|
304
|
+
logger.debug(
|
|
305
|
+
"LLM attempt completed request_id=%s attempt=%d/%d elapsed_s=%.3f "
|
|
306
|
+
"stream_events=%d first_token_latency_s=%s content_chars=%d "
|
|
307
|
+
"tool_calls=%d usage=%s",
|
|
308
|
+
request_id,
|
|
309
|
+
attempt,
|
|
310
|
+
attempts,
|
|
311
|
+
elapsed_seconds,
|
|
312
|
+
stream_event_count,
|
|
313
|
+
(
|
|
314
|
+
f"{first_stream_event_at - start:.3f}"
|
|
315
|
+
if first_stream_event_at is not None
|
|
316
|
+
else "none"
|
|
317
|
+
),
|
|
318
|
+
len(response.content or ""),
|
|
319
|
+
len(response.tool_calls or []),
|
|
320
|
+
response.usage,
|
|
321
|
+
)
|
|
322
|
+
return response
|
|
323
|
+
|
|
324
|
+
idle_seconds = int(time.monotonic() - last_activity)
|
|
325
|
+
elapsed_seconds = int(time.monotonic() - start)
|
|
326
|
+
logger.debug(
|
|
327
|
+
"LLM still waiting request_id=%s attempt=%d/%d elapsed_s=%d "
|
|
328
|
+
"idle_s=%d stream_events=%d remaining_s=%.3f",
|
|
329
|
+
request_id,
|
|
330
|
+
attempt,
|
|
331
|
+
attempts,
|
|
332
|
+
elapsed_seconds,
|
|
333
|
+
idle_seconds,
|
|
334
|
+
stream_event_count,
|
|
335
|
+
remaining,
|
|
336
|
+
)
|
|
337
|
+
await _emit_llm_event(
|
|
338
|
+
event_callback,
|
|
339
|
+
source=source,
|
|
340
|
+
session_id=session_id,
|
|
341
|
+
role=role,
|
|
342
|
+
parent_session_id=parent_session_id,
|
|
343
|
+
event_type="llm_waiting",
|
|
344
|
+
content=(
|
|
345
|
+
"waiting for model response... "
|
|
346
|
+
f"{elapsed_seconds}s elapsed, {idle_seconds}s since last token "
|
|
347
|
+
f"(attempt {attempt}/{attempts})"
|
|
348
|
+
),
|
|
349
|
+
title="Waiting for model response",
|
|
350
|
+
detail=f"Attempt {attempt}/{attempts}, {idle_seconds}s since last token",
|
|
351
|
+
phase="waiting",
|
|
352
|
+
status="running",
|
|
353
|
+
elapsed_ms=elapsed_seconds * 1000,
|
|
354
|
+
metadata={
|
|
355
|
+
"attempt": attempt,
|
|
356
|
+
"attempts": attempts,
|
|
357
|
+
"idle_seconds": idle_seconds,
|
|
358
|
+
"since_last_token_ms": idle_seconds * 1000,
|
|
359
|
+
"elapsed_seconds": elapsed_seconds,
|
|
360
|
+
"elapsed_ms": elapsed_seconds * 1000,
|
|
361
|
+
"source": source,
|
|
362
|
+
"role": role,
|
|
363
|
+
},
|
|
364
|
+
)
|
|
365
|
+
finally:
|
|
366
|
+
if not task.done():
|
|
367
|
+
task.cancel()
|
|
368
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
369
|
+
await task
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
async def _emit_llm_event(
|
|
373
|
+
event_callback: Optional[StreamEventCallback],
|
|
374
|
+
*,
|
|
375
|
+
source: str,
|
|
376
|
+
session_id: str,
|
|
377
|
+
role: Optional[str],
|
|
378
|
+
parent_session_id: Optional[str],
|
|
379
|
+
event_type: str,
|
|
380
|
+
content: str,
|
|
381
|
+
title: Optional[str] = None,
|
|
382
|
+
detail: Optional[str] = None,
|
|
383
|
+
phase: Optional[str] = None,
|
|
384
|
+
status: Optional[str] = None,
|
|
385
|
+
elapsed_ms: Optional[int] = None,
|
|
386
|
+
metadata: Optional[dict] = None,
|
|
387
|
+
) -> None:
|
|
388
|
+
if event_callback is None:
|
|
389
|
+
return
|
|
390
|
+
await event_callback(
|
|
391
|
+
StreamEvent(
|
|
392
|
+
source=source,
|
|
393
|
+
session_id=session_id,
|
|
394
|
+
role=role,
|
|
395
|
+
parent_session_id=parent_session_id,
|
|
396
|
+
event_type=event_type,
|
|
397
|
+
content=content,
|
|
398
|
+
title=title,
|
|
399
|
+
detail=detail,
|
|
400
|
+
phase=phase,
|
|
401
|
+
status=status,
|
|
402
|
+
elapsed_ms=elapsed_ms,
|
|
403
|
+
metadata=metadata,
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _resolve_float_env(name: str, explicit: Optional[float], default: float) -> float:
|
|
409
|
+
if explicit is not None:
|
|
410
|
+
return max(float(explicit), 0.001)
|
|
411
|
+
raw = os.getenv(name)
|
|
412
|
+
if raw is None:
|
|
413
|
+
return default
|
|
414
|
+
try:
|
|
415
|
+
return max(float(raw), 0.001)
|
|
416
|
+
except ValueError:
|
|
417
|
+
return default
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _resolve_int_env(name: str, explicit: Optional[int], default: int) -> int:
|
|
421
|
+
if explicit is not None:
|
|
422
|
+
return max(int(explicit), 0)
|
|
423
|
+
raw = os.getenv(name)
|
|
424
|
+
if raw is None:
|
|
425
|
+
return default
|
|
426
|
+
try:
|
|
427
|
+
return max(int(raw), 0)
|
|
428
|
+
except ValueError:
|
|
429
|
+
return default
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _debug_enabled() -> bool:
|
|
433
|
+
logger_module = sys.modules.get("agent.logger")
|
|
434
|
+
return bool(getattr(logger_module, "DEBUG_ENABLED", False))
|
agent/logger.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Logging configuration for the agent."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
# Log file path
|
|
8
|
+
LOG_FILE_NAME = "agent_debug.log"
|
|
9
|
+
LOG_FILE = Path(LOG_FILE_NAME)
|
|
10
|
+
|
|
11
|
+
# Global flag to control debug output
|
|
12
|
+
DEBUG_ENABLED = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def setup_logging(
|
|
16
|
+
debug: bool = False,
|
|
17
|
+
log_to_file: bool = False,
|
|
18
|
+
log_file: str | Path | None = None,
|
|
19
|
+
):
|
|
20
|
+
"""Set up logging configuration.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
debug: Whether to enable debug logging to console.
|
|
24
|
+
log_to_file: Whether to write logs to file.
|
|
25
|
+
log_file: Optional path for the log file.
|
|
26
|
+
"""
|
|
27
|
+
global DEBUG_ENABLED
|
|
28
|
+
DEBUG_ENABLED = debug
|
|
29
|
+
|
|
30
|
+
# Root logger configuration
|
|
31
|
+
root_logger = logging.getLogger()
|
|
32
|
+
root_logger.setLevel(logging.DEBUG)
|
|
33
|
+
|
|
34
|
+
# Clear any existing handlers
|
|
35
|
+
for handler in root_logger.handlers[:]:
|
|
36
|
+
root_logger.removeHandler(handler)
|
|
37
|
+
|
|
38
|
+
# File handler - only if log_to_file is True
|
|
39
|
+
log_path = Path(log_file).expanduser().resolve() if log_file else LOG_FILE
|
|
40
|
+
if log_to_file:
|
|
41
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
file_formatter = logging.Formatter(
|
|
43
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
44
|
+
)
|
|
45
|
+
file_handler = logging.FileHandler(log_path, encoding="utf-8")
|
|
46
|
+
file_handler.setLevel(logging.DEBUG)
|
|
47
|
+
file_handler.setFormatter(file_formatter)
|
|
48
|
+
root_logger.addHandler(file_handler)
|
|
49
|
+
|
|
50
|
+
# Console handler - always present, but level depends on debug
|
|
51
|
+
console_formatter = logging.Formatter(
|
|
52
|
+
"%(levelname)s: %(message)s"
|
|
53
|
+
)
|
|
54
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
|
55
|
+
|
|
56
|
+
if debug:
|
|
57
|
+
console_handler.setLevel(logging.DEBUG)
|
|
58
|
+
else:
|
|
59
|
+
console_handler.setLevel(logging.WARNING)
|
|
60
|
+
|
|
61
|
+
console_handler.setFormatter(console_formatter)
|
|
62
|
+
root_logger.addHandler(console_handler)
|
|
63
|
+
|
|
64
|
+
# Confirm logging setup
|
|
65
|
+
status_parts = []
|
|
66
|
+
if debug:
|
|
67
|
+
status_parts.append("debug to console")
|
|
68
|
+
if log_to_file:
|
|
69
|
+
status_parts.append(f"logs to {log_path}")
|
|
70
|
+
|
|
71
|
+
if status_parts:
|
|
72
|
+
print(f"\033[90m[INFO] {', '.join(status_parts)}\033[0m")
|
|
73
|
+
else:
|
|
74
|
+
print("\033[90m[INFO] No logging configured\033[0m")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_logger(name: str) -> logging.Logger:
|
|
78
|
+
"""Get a named logger.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
name: Logger name, usually __name__.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Configured logger instance.
|
|
85
|
+
"""
|
|
86
|
+
return logging.getLogger(name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def debug_print(*args, **kwargs):
|
|
90
|
+
"""Print debug messages only if DEBUG is enabled.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
*args: Positional arguments to print.
|
|
94
|
+
**kwargs: Keyword arguments to print.
|
|
95
|
+
"""
|
|
96
|
+
if DEBUG_ENABLED:
|
|
97
|
+
print(*args, **kwargs)
|
agent/lsp/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""LSP integration package."""
|
|
2
|
+
|
|
3
|
+
from agent.lsp.manager import LspManager, get_lsp_manager, shutdown_lsp_managers
|
|
4
|
+
from agent.lsp.types import Diagnostic, Location, Symbol
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Diagnostic",
|
|
8
|
+
"Location",
|
|
9
|
+
"LspManager",
|
|
10
|
+
"Symbol",
|
|
11
|
+
"get_lsp_manager",
|
|
12
|
+
"shutdown_lsp_managers",
|
|
13
|
+
]
|
agent/lsp/client.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Minimal async JSON-RPC client for Language Server Protocol processes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LspClientError(RuntimeError):
|
|
13
|
+
"""Raised when an LSP client operation fails."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LspClient:
|
|
17
|
+
"""Small JSON-RPC client for a single language server process."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, command: Sequence[str], root: Path, timeout: float = 10.0):
|
|
20
|
+
self.command = list(command)
|
|
21
|
+
self.root = root.resolve()
|
|
22
|
+
self.timeout = timeout
|
|
23
|
+
self.process: asyncio.subprocess.Process | None = None
|
|
24
|
+
self._next_id = 1
|
|
25
|
+
self._pending: dict[int, asyncio.Future] = {}
|
|
26
|
+
self._reader_task: asyncio.Task | None = None
|
|
27
|
+
self._initialized = False
|
|
28
|
+
|
|
29
|
+
async def start(self) -> None:
|
|
30
|
+
"""Start and initialize the language server."""
|
|
31
|
+
if self.process is not None and self.process.returncode is None:
|
|
32
|
+
return
|
|
33
|
+
self.process = await asyncio.create_subprocess_exec(
|
|
34
|
+
*self.command,
|
|
35
|
+
stdin=asyncio.subprocess.PIPE,
|
|
36
|
+
stdout=asyncio.subprocess.PIPE,
|
|
37
|
+
stderr=asyncio.subprocess.PIPE,
|
|
38
|
+
cwd=str(self.root),
|
|
39
|
+
)
|
|
40
|
+
self._reader_task = asyncio.create_task(self._read_loop())
|
|
41
|
+
await self.initialize()
|
|
42
|
+
|
|
43
|
+
async def initialize(self) -> None:
|
|
44
|
+
"""Send initialize/initialized handshake."""
|
|
45
|
+
if self._initialized:
|
|
46
|
+
return
|
|
47
|
+
result = await self.request(
|
|
48
|
+
"initialize",
|
|
49
|
+
{
|
|
50
|
+
"processId": None,
|
|
51
|
+
"rootUri": self.root.as_uri(),
|
|
52
|
+
"capabilities": {},
|
|
53
|
+
"workspaceFolders": [{"uri": self.root.as_uri(), "name": self.root.name}],
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
if result is None:
|
|
57
|
+
raise LspClientError("language server returned empty initialize result")
|
|
58
|
+
await self.notify("initialized", {})
|
|
59
|
+
self._initialized = True
|
|
60
|
+
|
|
61
|
+
async def request(self, method: str, params: dict[str, Any] | None = None) -> Any:
|
|
62
|
+
"""Send a JSON-RPC request and return its result."""
|
|
63
|
+
if self.process is None or self.process.stdin is None:
|
|
64
|
+
raise LspClientError("language server is not running")
|
|
65
|
+
request_id = self._next_id
|
|
66
|
+
self._next_id += 1
|
|
67
|
+
loop = asyncio.get_running_loop()
|
|
68
|
+
future = loop.create_future()
|
|
69
|
+
self._pending[request_id] = future
|
|
70
|
+
await self._send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}})
|
|
71
|
+
try:
|
|
72
|
+
response = await asyncio.wait_for(future, timeout=self.timeout)
|
|
73
|
+
finally:
|
|
74
|
+
self._pending.pop(request_id, None)
|
|
75
|
+
if "error" in response:
|
|
76
|
+
error = response["error"]
|
|
77
|
+
message = error.get("message", str(error)) if isinstance(error, dict) else str(error)
|
|
78
|
+
raise LspClientError(message)
|
|
79
|
+
return response.get("result")
|
|
80
|
+
|
|
81
|
+
async def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
82
|
+
"""Send a JSON-RPC notification."""
|
|
83
|
+
if self.process is None or self.process.stdin is None:
|
|
84
|
+
raise LspClientError("language server is not running")
|
|
85
|
+
await self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
|
|
86
|
+
|
|
87
|
+
async def did_open(self, uri: str, text: str, language_id: str = "python") -> None:
|
|
88
|
+
"""Notify the server that a document is open."""
|
|
89
|
+
await self.notify(
|
|
90
|
+
"textDocument/didOpen",
|
|
91
|
+
{"textDocument": {"uri": uri, "languageId": language_id, "version": 1, "text": text}},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async def shutdown(self) -> None:
|
|
95
|
+
"""Shutdown the language server process."""
|
|
96
|
+
proc = self.process
|
|
97
|
+
if proc is None:
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
if proc.returncode is None:
|
|
101
|
+
try:
|
|
102
|
+
await self.request("shutdown", {})
|
|
103
|
+
await self.notify("exit", {})
|
|
104
|
+
except Exception:
|
|
105
|
+
proc.terminate()
|
|
106
|
+
try:
|
|
107
|
+
await asyncio.wait_for(proc.wait(), timeout=2)
|
|
108
|
+
except asyncio.TimeoutError:
|
|
109
|
+
proc.kill()
|
|
110
|
+
await proc.wait()
|
|
111
|
+
finally:
|
|
112
|
+
if self._reader_task:
|
|
113
|
+
self._reader_task.cancel()
|
|
114
|
+
self.process = None
|
|
115
|
+
self._initialized = False
|
|
116
|
+
|
|
117
|
+
async def _send(self, payload: dict[str, Any]) -> None:
|
|
118
|
+
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
119
|
+
header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
|
|
120
|
+
assert self.process is not None and self.process.stdin is not None
|
|
121
|
+
self.process.stdin.write(header + body)
|
|
122
|
+
await self.process.stdin.drain()
|
|
123
|
+
|
|
124
|
+
async def _read_loop(self) -> None:
|
|
125
|
+
assert self.process is not None and self.process.stdout is not None
|
|
126
|
+
reader = self.process.stdout
|
|
127
|
+
while True:
|
|
128
|
+
try:
|
|
129
|
+
headers: dict[str, str] = {}
|
|
130
|
+
while True:
|
|
131
|
+
line = await reader.readline()
|
|
132
|
+
if not line:
|
|
133
|
+
raise EOFError("language server stdout closed")
|
|
134
|
+
if line in {b"\r\n", b"\n"}:
|
|
135
|
+
break
|
|
136
|
+
key, _, value = line.decode("ascii", errors="replace").partition(":")
|
|
137
|
+
headers[key.lower()] = value.strip()
|
|
138
|
+
length = int(headers.get("content-length", "0"))
|
|
139
|
+
if length <= 0:
|
|
140
|
+
continue
|
|
141
|
+
body = await reader.readexactly(length)
|
|
142
|
+
message = json.loads(body.decode("utf-8"))
|
|
143
|
+
if "id" in message and (future := self._pending.get(int(message["id"]))) and not future.done():
|
|
144
|
+
future.set_result(message)
|
|
145
|
+
except asyncio.CancelledError:
|
|
146
|
+
raise
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
for future in list(self._pending.values()):
|
|
149
|
+
if not future.done():
|
|
150
|
+
future.set_exception(exc)
|
|
151
|
+
return
|