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/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