bareagent-cli 0.1.0__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 (121) hide show
  1. bareagent/__init__.py +10 -0
  2. bareagent/concurrency/__init__.py +6 -0
  3. bareagent/concurrency/background.py +97 -0
  4. bareagent/concurrency/notification.py +61 -0
  5. bareagent/concurrency/scheduler.py +136 -0
  6. bareagent/config.toml +299 -0
  7. bareagent/core/__init__.py +1 -0
  8. bareagent/core/config_paths.py +49 -0
  9. bareagent/core/context.py +127 -0
  10. bareagent/core/fileutil.py +103 -0
  11. bareagent/core/goal.py +214 -0
  12. bareagent/core/handlers/__init__.py +1 -0
  13. bareagent/core/handlers/bash.py +79 -0
  14. bareagent/core/handlers/file_edit.py +47 -0
  15. bareagent/core/handlers/file_read.py +270 -0
  16. bareagent/core/handlers/file_write.py +34 -0
  17. bareagent/core/handlers/glob_search.py +30 -0
  18. bareagent/core/handlers/goal.py +60 -0
  19. bareagent/core/handlers/grep_search.py +52 -0
  20. bareagent/core/handlers/memory.py +71 -0
  21. bareagent/core/handlers/plan.py +106 -0
  22. bareagent/core/handlers/search_utils.py +77 -0
  23. bareagent/core/handlers/skill.py +87 -0
  24. bareagent/core/handlers/subagent_send.py +70 -0
  25. bareagent/core/handlers/web_fetch.py +126 -0
  26. bareagent/core/handlers/web_search.py +165 -0
  27. bareagent/core/handlers/workflow.py +190 -0
  28. bareagent/core/loop.py +535 -0
  29. bareagent/core/retry.py +131 -0
  30. bareagent/core/sandbox.py +27 -0
  31. bareagent/core/schema.py +21 -0
  32. bareagent/core/tools.py +779 -0
  33. bareagent/core/workflow.py +517 -0
  34. bareagent/core/workflow_registry.py +219 -0
  35. bareagent/debug/__init__.py +0 -0
  36. bareagent/debug/interaction_log.py +263 -0
  37. bareagent/debug/viewer.html +1750 -0
  38. bareagent/debug/web_viewer.py +157 -0
  39. bareagent/hooks/__init__.py +32 -0
  40. bareagent/hooks/config.py +118 -0
  41. bareagent/hooks/engine.py +197 -0
  42. bareagent/hooks/errors.py +14 -0
  43. bareagent/hooks/events.py +22 -0
  44. bareagent/lsp/__init__.py +63 -0
  45. bareagent/lsp/config.py +134 -0
  46. bareagent/lsp/coord.py +118 -0
  47. bareagent/lsp/diagnostics.py +240 -0
  48. bareagent/lsp/errors.py +24 -0
  49. bareagent/lsp/manager.py +866 -0
  50. bareagent/lsp/tools.py +629 -0
  51. bareagent/lsp/workspace_edit.py +305 -0
  52. bareagent/main.py +4205 -0
  53. bareagent/mcp/__init__.py +69 -0
  54. bareagent/mcp/_sse.py +69 -0
  55. bareagent/mcp/client.py +341 -0
  56. bareagent/mcp/config.py +169 -0
  57. bareagent/mcp/errors.py +32 -0
  58. bareagent/mcp/manager.py +318 -0
  59. bareagent/mcp/protocol.py +187 -0
  60. bareagent/mcp/registry.py +557 -0
  61. bareagent/mcp/transport/__init__.py +15 -0
  62. bareagent/mcp/transport/base.py +149 -0
  63. bareagent/mcp/transport/http_legacy.py +192 -0
  64. bareagent/mcp/transport/http_streamable.py +217 -0
  65. bareagent/mcp/transport/stdio.py +202 -0
  66. bareagent/memory/__init__.py +1 -0
  67. bareagent/memory/compact.py +203 -0
  68. bareagent/memory/conversation_io.py +226 -0
  69. bareagent/memory/embedding.py +194 -0
  70. bareagent/memory/persistent.py +515 -0
  71. bareagent/memory/token_counter.py +67 -0
  72. bareagent/memory/token_tracker.py +262 -0
  73. bareagent/memory/transcript.py +100 -0
  74. bareagent/permission/__init__.py +1 -0
  75. bareagent/permission/guard.py +329 -0
  76. bareagent/permission/rules.py +19 -0
  77. bareagent/planning/__init__.py +19 -0
  78. bareagent/planning/agent_types.py +169 -0
  79. bareagent/planning/skill_gen.py +141 -0
  80. bareagent/planning/skill_store.py +173 -0
  81. bareagent/planning/skills.py +146 -0
  82. bareagent/planning/subagent.py +355 -0
  83. bareagent/planning/subagent_registry.py +77 -0
  84. bareagent/planning/tasks.py +348 -0
  85. bareagent/planning/todo.py +153 -0
  86. bareagent/planning/worktree.py +122 -0
  87. bareagent/provider/__init__.py +1 -0
  88. bareagent/provider/anthropic.py +348 -0
  89. bareagent/provider/base.py +136 -0
  90. bareagent/provider/factory.py +130 -0
  91. bareagent/provider/openai.py +881 -0
  92. bareagent/provider/presets.py +72 -0
  93. bareagent/provider/setup.py +356 -0
  94. bareagent/skills/.gitkeep +1 -0
  95. bareagent/skills/code-review/SKILL.md +68 -0
  96. bareagent/skills/git/SKILL.md +68 -0
  97. bareagent/skills/test/SKILL.md +70 -0
  98. bareagent/team/__init__.py +17 -0
  99. bareagent/team/autonomous.py +193 -0
  100. bareagent/team/mailbox.py +239 -0
  101. bareagent/team/manager.py +155 -0
  102. bareagent/team/protocols.py +129 -0
  103. bareagent/tracing/__init__.py +12 -0
  104. bareagent/tracing/_api.py +92 -0
  105. bareagent/tracing/_proxy.py +60 -0
  106. bareagent/tracing/composite.py +115 -0
  107. bareagent/tracing/json_file.py +115 -0
  108. bareagent/tracing/langfuse.py +139 -0
  109. bareagent/tracing/otel.py +107 -0
  110. bareagent/tracing/setup.py +85 -0
  111. bareagent/ui/__init__.py +24 -0
  112. bareagent/ui/console.py +167 -0
  113. bareagent/ui/prompt.py +78 -0
  114. bareagent/ui/protocol.py +24 -0
  115. bareagent/ui/stream.py +66 -0
  116. bareagent/ui/theme.py +240 -0
  117. bareagent_cli-0.1.0.dist-info/METADATA +331 -0
  118. bareagent_cli-0.1.0.dist-info/RECORD +121 -0
  119. bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
  120. bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  121. bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,134 @@
1
+ """LSP server configuration parsing.
2
+
3
+ Reads a ``[lsp]`` block (plus ``[[lsp.servers]]`` array) from a TOML-derived
4
+ dict and returns typed dataclasses. Each server declares the multilspy
5
+ ``code_language`` and the file extensions that route to it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ from .errors import LSPError
14
+
15
+ # 15s covers pyright startup + initial project scan on a medium repo. Larger
16
+ # projects (or rust-analyzer / jdtls) may need to bump this in user config.
17
+ _DEFAULT_START_TIMEOUT = 15.0
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class LSPServerConfig:
22
+ """One language server entry."""
23
+
24
+ # multilspy ``code_language`` value (e.g. ``"python"`` / ``"typescript"`` /
25
+ # ``"rust"``). Must be unique across the config — duplicates are rejected
26
+ # by :func:`parse_lsp_config`.
27
+ language: str
28
+ # File suffixes (lowercased, leading dot) that route to this server.
29
+ extensions: list[str] = field(default_factory=list)
30
+ # Passed through to multilspy as ``initialization_options`` (LSP-specific
31
+ # config; e.g. pyright's ``python.pythonPath``). ``None`` means "send no
32
+ # options" — equivalent to ``null`` in LSP wire format.
33
+ initialization_options: dict[str, Any] | None = None
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class LSPConfig:
38
+ """Top-level LSP configuration."""
39
+
40
+ servers: list[LSPServerConfig] = field(default_factory=list)
41
+ # Hybrid auto-diagnostics-on-edit feature flag. Parsed in this PR for
42
+ # forward compatibility; the actual consumer lands in child B.
43
+ auto_diagnostics_on_edit: bool = False
44
+ # Per-server start timeout (seconds). Each server handshake gets this
45
+ # budget independently inside ``LanguageServerManager.start_all``.
46
+ start_timeout: float = _DEFAULT_START_TIMEOUT
47
+
48
+
49
+ def parse_lsp_config(raw: dict[str, Any]) -> LSPConfig:
50
+ """Parse a TOML-derived dict (the ``[lsp]`` section) into ``LSPConfig``.
51
+
52
+ Accepts either the full document (where ``lsp`` is a key) or the ``[lsp]``
53
+ block itself. Missing or empty ``lsp`` yields an empty config with
54
+ ``servers=[]``. Unknown keys are silently ignored to stay forward-compatible.
55
+ """
56
+ if not isinstance(raw, dict):
57
+ raise LSPError(f"lsp config must be a table, got {type(raw).__name__}")
58
+
59
+ block = raw.get("lsp", raw)
60
+ if block is None:
61
+ return LSPConfig()
62
+ if not isinstance(block, dict):
63
+ raise LSPError("'lsp' must be a table")
64
+
65
+ cfg = LSPConfig(
66
+ auto_diagnostics_on_edit=_bool(block, "auto_diagnostics_on_edit", False),
67
+ start_timeout=_float(block, "start_timeout", _DEFAULT_START_TIMEOUT),
68
+ )
69
+
70
+ servers_raw = block.get("servers", [])
71
+ if not isinstance(servers_raw, list):
72
+ raise LSPError("'lsp.servers' must be an array of tables")
73
+
74
+ seen: set[str] = set()
75
+ for index, entry in enumerate(servers_raw):
76
+ if not isinstance(entry, dict):
77
+ raise LSPError(f"lsp.servers[{index}] must be a table")
78
+ server = _parse_server(entry, index)
79
+ if server.language in seen:
80
+ raise LSPError(f"duplicate lsp server language: {server.language!r}")
81
+ seen.add(server.language)
82
+ cfg.servers.append(server)
83
+ return cfg
84
+
85
+
86
+ def _parse_server(entry: dict[str, Any], index: int) -> LSPServerConfig:
87
+ language = entry.get("language")
88
+ if not isinstance(language, str) or not language:
89
+ raise LSPError(
90
+ f"lsp.servers[{index}].language is required and must be a non-empty string"
91
+ )
92
+
93
+ extensions_raw = entry.get("extensions")
94
+ if not isinstance(extensions_raw, list) or not extensions_raw:
95
+ raise LSPError(
96
+ f"lsp.servers[{language}].extensions is required and must be a "
97
+ "non-empty list of strings"
98
+ )
99
+ if not all(isinstance(ext, str) and ext for ext in extensions_raw):
100
+ raise LSPError(
101
+ f"lsp.servers[{language}].extensions must contain non-empty strings"
102
+ )
103
+ extensions = [ext.lower() for ext in extensions_raw]
104
+ for ext in extensions:
105
+ if not ext.startswith("."):
106
+ raise LSPError(
107
+ f"lsp.servers[{language}].extensions entries must start with '.', got {ext!r}"
108
+ )
109
+
110
+ init_options = entry.get("initialization_options")
111
+ if init_options is not None and not isinstance(init_options, dict):
112
+ raise LSPError(
113
+ f"lsp.servers[{language}].initialization_options must be a table if provided"
114
+ )
115
+
116
+ return LSPServerConfig(
117
+ language=language,
118
+ extensions=extensions,
119
+ initialization_options=dict(init_options) if init_options is not None else None,
120
+ )
121
+
122
+
123
+ def _bool(block: dict[str, Any], key: str, default: bool) -> bool:
124
+ value = block.get(key, default)
125
+ if not isinstance(value, bool):
126
+ raise LSPError(f"lsp.{key} must be a boolean, got {value!r}")
127
+ return value
128
+
129
+
130
+ def _float(block: dict[str, Any], key: str, default: float) -> float:
131
+ value = block.get(key, default)
132
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
133
+ raise LSPError(f"lsp.{key} must be a number, got {value!r}")
134
+ return float(value)
bareagent/lsp/coord.py ADDED
@@ -0,0 +1,118 @@
1
+ """Coordinate and URI helpers shared by every LSP tool handler.
2
+
3
+ Tools take 1-based (line, column) from the LLM (matching editor convention)
4
+ and convert to 0-based for LSP requests. Results are converted back to
5
+ 1-based before they reach the model.
6
+
7
+ The URI helpers prefer multilspy's ``PathUtils`` when available; otherwise
8
+ they fall back to a hand-written ``file:///`` construction that mirrors the
9
+ forms multilspy / the major language servers accept (Windows drive letters
10
+ upper-cased, no ``%3A`` encoding of the colon).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from pathlib import Path
17
+ from typing import cast
18
+ from urllib.parse import unquote, urlparse
19
+
20
+
21
+ def line_col_1_to_0(line: int, col: int) -> tuple[int, int]:
22
+ """Convert 1-based editor coordinates to 0-based LSP coordinates.
23
+
24
+ Position (1, 1) (the first character of the first line) maps to
25
+ (0, 0). Values < 1 are clamped to 0 — LSP servers tolerate clamping
26
+ better than they tolerate negative indices.
27
+ """
28
+ return (max(line - 1, 0), max(col - 1, 0))
29
+
30
+
31
+ def line_col_0_to_1(line: int, col: int) -> tuple[int, int]:
32
+ """Convert 0-based LSP coordinates back to 1-based editor coordinates."""
33
+ return (line + 1, col + 1)
34
+
35
+
36
+ def path_to_document_uri(path: str) -> str:
37
+ """Convert a filesystem path to a ``file://`` URI.
38
+
39
+ Prefers multilspy's ``PathUtils.path_to_uri`` when available so the URI
40
+ shape matches what multilspy internally sends to the server. The fallback
41
+ constructs ``file:///<abs-path>`` directly, leaving Windows drive letters
42
+ upper-cased and the colon un-encoded (the form rust-analyzer, pyright,
43
+ gopls all accept).
44
+ """
45
+ helper = _multilspy_path_to_uri()
46
+ if helper is not None:
47
+ try:
48
+ return helper(path)
49
+ except Exception:
50
+ pass # fall through to manual construction
51
+
52
+ abs_path = os.path.abspath(path)
53
+ # Normalize separators to forward slashes for the URI form.
54
+ normalized = abs_path.replace("\\", "/")
55
+ if normalized.startswith("/"):
56
+ # POSIX absolute path: file:///abs/path
57
+ return f"file://{normalized}"
58
+ # Windows: D:/code/... → file:///D:/code/...
59
+ return f"file:///{normalized}"
60
+
61
+
62
+ def document_uri_to_path(uri: str) -> str:
63
+ """Convert a ``file://`` URI back to a native filesystem path.
64
+
65
+ Handles both ``file:///D:/foo`` and ``file:///D%3A/foo`` (percent-encoded
66
+ drive letter) forms. Non-``file:`` URIs are returned unchanged so callers
67
+ can decide how to handle remote / virtual documents.
68
+ """
69
+ if not uri.startswith("file:"):
70
+ return uri
71
+ parsed = urlparse(uri)
72
+ raw = unquote(parsed.path)
73
+ # On Windows ``urlparse('file:///D:/foo').path`` yields ``/D:/foo``; strip
74
+ # the leading slash so the result is a real native path.
75
+ if os.name == "nt" and len(raw) >= 3 and raw[0] == "/" and raw[2] == ":":
76
+ raw = raw[1:]
77
+ return raw
78
+
79
+
80
+ def to_repo_relative(path: str, repository_root: str) -> str:
81
+ """Convert an absolute or relative file path to a path relative to the
82
+ repository root.
83
+
84
+ multilspy's request_* methods expect a relative path. If ``path`` is
85
+ already relative, return it unchanged. If it cannot be made relative to
86
+ ``repository_root`` (e.g. on a different drive), return the original
87
+ ``path`` and let multilspy report the error.
88
+ """
89
+ try:
90
+ return os.path.relpath(path, start=repository_root)
91
+ except ValueError:
92
+ return path
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # multilspy path-utils delegation
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ def _multilspy_path_to_uri(): # pragma: no cover — exercised only when multilspy is present
101
+ """Return ``multilspy.PathUtils.path_to_uri`` (or None) without crashing.
102
+
103
+ Kept in a helper so the import is attempted lazily and never raises out
104
+ to the caller. multilspy's exact module path differs across versions;
105
+ we probe the most common ones.
106
+ """
107
+ try:
108
+ from multilspy.multilspy_utils import PathUtils # type: ignore
109
+ except Exception:
110
+ return None
111
+ helper = getattr(PathUtils, "path_to_uri", None)
112
+ if not callable(helper):
113
+ return None
114
+
115
+ def _bound(path: str) -> str:
116
+ return cast(str, helper(str(Path(path).resolve())))
117
+
118
+ return _bound
@@ -0,0 +1,240 @@
1
+ """Diagnostic snapshot + diff helpers for the Hybrid auto-diagnostics hook.
2
+
3
+ Surfaces three small primitives:
4
+
5
+ * :func:`snapshot_diagnostics` — read whatever diagnostics the LSP manager has
6
+ for ``file_path`` right now, as a normalized list of :class:`Diagnostic`.
7
+ * :func:`diff_diagnostics` — given two snapshots (before / after), return the
8
+ rows that appeared in ``after`` but not in ``before``. Equivalence is the
9
+ five-tuple ``(file, line, col, severity, message)`` (see :class:`DiagnosticKey`).
10
+ * :func:`maybe_diagnostics_appendix` — handler-side entry point that wires the
11
+ pieces together and answers "should I append a diagnostics paragraph to my
12
+ tool result?". Returns ``None`` whenever LSP is unavailable, the config flag
13
+ is off, or there were no newly-introduced diagnostics — i.e. the **happy
14
+ path returns None** so the cost of feature-disabled callers is ~zero.
15
+
16
+ multilspy 0.0.15 explicitly registers ``do_nothing`` for
17
+ ``textDocument/publishDiagnostics`` on every bundled language-server adapter
18
+ (see ``multilspy/language_servers/*/<server>.py``). There is no public pull-
19
+ diagnostics surface on ``SyncLanguageServer`` either. To bridge the gap, the
20
+ manager hooks the underlying ``LanguageServerHandler.on_notification_handlers``
21
+ post-handshake and routes publishDiagnostics into a per-server cache; this
22
+ module just reads from there via ``manager.get_diagnostics_snapshot(...)``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ if TYPE_CHECKING:
31
+ from .config import LSPConfig
32
+ from .manager import LanguageServerManager
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class Diagnostic:
37
+ """Normalized view of one LSP diagnostic.
38
+
39
+ ``line`` / ``col`` are **1-based** (matching the rest of the LSP tool
40
+ surface). ``severity`` is the editor-friendly label produced by
41
+ :func:`_severity_label` (e.g. ``"Error"``).
42
+ """
43
+
44
+ file: str
45
+ line: int
46
+ col: int
47
+ severity: str
48
+ message: str
49
+ source: str = ""
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class DiagnosticKey:
54
+ """Five-tuple identity for a diagnostic — see PRD diff algorithm.
55
+
56
+ Two ``Diagnostic`` values that produce the same ``DiagnosticKey`` are
57
+ treated as the same diagnostic for diff purposes. ``source`` is excluded
58
+ deliberately: pyright sometimes leaves it blank, so including it would
59
+ cause spurious "newly introduced" hits on otherwise identical rows.
60
+ """
61
+
62
+ file: str
63
+ line: int
64
+ col: int
65
+ severity: str
66
+ message: str
67
+
68
+ @classmethod
69
+ def from_diag(cls, diag: Diagnostic) -> DiagnosticKey:
70
+ return cls(
71
+ file=diag.file,
72
+ line=diag.line,
73
+ col=diag.col,
74
+ severity=diag.severity,
75
+ message=diag.message,
76
+ )
77
+
78
+
79
+ _SEVERITY_LABELS = {1: "Error", 2: "Warning", 3: "Info", 4: "Hint"}
80
+
81
+
82
+ def _severity_label(severity: Any) -> str:
83
+ try:
84
+ return _SEVERITY_LABELS.get(int(severity), "Diagnostic")
85
+ except (TypeError, ValueError):
86
+ return "Diagnostic"
87
+
88
+
89
+ def _normalize(file_path: str, raw: Any) -> Diagnostic | None:
90
+ """Convert a raw multilspy / LSP payload dict into a :class:`Diagnostic`.
91
+
92
+ Returns ``None`` when the row is too malformed to be useful (e.g. no
93
+ ``range``). The handlers never trust LSP server output blindly — pyright
94
+ has been known to send notifications with empty arrays, and our diff
95
+ algorithm requires a numeric position to compare against.
96
+ """
97
+ if not isinstance(raw, dict):
98
+ return None
99
+ range_ = raw.get("range") or {}
100
+ start = range_.get("start") if isinstance(range_, dict) else None
101
+ if not isinstance(start, dict):
102
+ return None
103
+ try:
104
+ line0 = int(start.get("line", 0) or 0)
105
+ col0 = int(start.get("character", 0) or 0)
106
+ except (TypeError, ValueError):
107
+ return None
108
+ severity = _severity_label(raw.get("severity"))
109
+ message = str(raw.get("message", ""))
110
+ source = str(raw.get("source", "") or "")
111
+ return Diagnostic(
112
+ file=file_path,
113
+ line=line0 + 1, # 0-based LSP → 1-based for display + diff
114
+ col=col0 + 1,
115
+ severity=severity,
116
+ message=message,
117
+ source=source,
118
+ )
119
+
120
+
121
+ def snapshot_diagnostics(
122
+ manager: LanguageServerManager,
123
+ file_path: str,
124
+ ) -> list[Diagnostic]:
125
+ """Return the manager's cached diagnostics for ``file_path``.
126
+
127
+ Reads through :meth:`LanguageServerManager.get_diagnostics_snapshot`, which
128
+ drains whatever ``publishDiagnostics`` notifications have arrived from the
129
+ server so far. Returns an empty list when:
130
+
131
+ * the file does not route to any configured server,
132
+ * the routed server is not RUNNING,
133
+ * the server hasn't published any diagnostics for the file yet.
134
+ """
135
+ raw_rows = manager.get_diagnostics_snapshot(file_path)
136
+ out: list[Diagnostic] = []
137
+ for raw in raw_rows:
138
+ diag = _normalize(file_path, raw)
139
+ if diag is not None:
140
+ out.append(diag)
141
+ return out
142
+
143
+
144
+ def diff_diagnostics(
145
+ before: list[Diagnostic],
146
+ after: list[Diagnostic],
147
+ ) -> list[Diagnostic]:
148
+ """Return rows in ``after`` whose five-tuple key is absent from ``before``.
149
+
150
+ Order follows ``after`` so the appendix reads top-to-bottom in source
151
+ order. The function is pure — both inputs may be reused.
152
+ """
153
+ before_keys = {DiagnosticKey.from_diag(d) for d in before}
154
+ return [d for d in after if DiagnosticKey.from_diag(d) not in before_keys]
155
+
156
+
157
+ def format_diagnostics(file_path: str, diags: list[Diagnostic]) -> str:
158
+ """Render newly-introduced diagnostics as a stable text block.
159
+
160
+ The format is fixed by the PRD so downstream tooling (and the LLM) can
161
+ detect the appendix by prefix::
162
+
163
+ Newly introduced diagnostics in <file>:
164
+ - [pyright Error] Line 12:5 — Cannot assign to variable 'x' because of its type
165
+
166
+ ``source`` defaults to ``"lsp"`` when the server didn't include one. The
167
+ leading file header is always emitted even if ``diags`` is empty so callers
168
+ that pre-filter still produce a useful message; in practice the caller
169
+ (``maybe_diagnostics_appendix``) skips the empty case entirely.
170
+ """
171
+ header = f"Newly introduced diagnostics in {file_path}:"
172
+ if not diags:
173
+ return header
174
+ lines = [header]
175
+ for diag in diags:
176
+ source = diag.source or "lsp"
177
+ lines.append(
178
+ f"- [{source} {diag.severity}] Line {diag.line}:{diag.col} — {diag.message}"
179
+ )
180
+ return "\n".join(lines)
181
+
182
+
183
+ def maybe_diagnostics_appendix(
184
+ manager: LanguageServerManager | None,
185
+ lsp_config: LSPConfig | None,
186
+ file_path: str,
187
+ before: list[Diagnostic] | None,
188
+ ) -> str | None:
189
+ """Best-effort hook for edit/write handlers.
190
+
191
+ Returns the formatted appendix (with leading ``\\n\\n`` so the caller can
192
+ just ``result + appendix``) when **all** of the following are true:
193
+
194
+ * ``manager`` and ``lsp_config`` are both provided,
195
+ * ``lsp_config.auto_diagnostics_on_edit`` is True,
196
+ * the file routes to a RUNNING server,
197
+ * the after-snapshot has rows that were absent in ``before``.
198
+
199
+ Returns ``None`` otherwise. The config gate is the first check so callers
200
+ that disabled the feature pay only an attribute access (≪ 1µs). Any
201
+ unexpected exception is swallowed — the handler must keep working even if
202
+ the LSP subsystem is misbehaving.
203
+ """
204
+ if manager is None or lsp_config is None:
205
+ return None
206
+ if not lsp_config.auto_diagnostics_on_edit:
207
+ return None
208
+ if manager.language_for_file(file_path) is None:
209
+ return None
210
+ # ``get_server_for_file`` returns None when the server isn't RUNNING,
211
+ # which we treat the same as "no diagnostics to compare" — drop out
212
+ # silently rather than spamming an Error line on every edit.
213
+ if manager.get_server_for_file(file_path) is None:
214
+ return None
215
+
216
+ try:
217
+ # Briefly wait for pyright/etc. to publish the latest analysis pass.
218
+ # The manager exposes the per-file Event; a 1.5s budget is enough for
219
+ # incremental analysis on a medium repo and short enough to avoid
220
+ # noticeable handler latency. Race condition mitigation modeled after
221
+ # Serena's analysis_complete Event (see PRD Technical Approach).
222
+ manager.wait_for_diagnostics(file_path, timeout=1.5)
223
+ after = snapshot_diagnostics(manager, file_path)
224
+ except Exception:
225
+ return None
226
+
227
+ new_diags = diff_diagnostics(before or [], after)
228
+ if not new_diags:
229
+ return None
230
+ return "\n\n" + format_diagnostics(file_path, new_diags)
231
+
232
+
233
+ __all__ = [
234
+ "Diagnostic",
235
+ "DiagnosticKey",
236
+ "diff_diagnostics",
237
+ "format_diagnostics",
238
+ "maybe_diagnostics_appendix",
239
+ "snapshot_diagnostics",
240
+ ]
@@ -0,0 +1,24 @@
1
+ """LSP error hierarchy.
2
+
3
+ Layered failure types: handshake (initialize lifecycle) and call (a request to
4
+ the language server raised or timed out). LSP tool execution failures
5
+ (``request_*`` returning empty or a server-side error) are NOT exceptions —
6
+ they flow back to the LLM as text via the tool handlers (see ``tools.py``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class LSPError(Exception):
13
+ """Base class for all LSP-related failures."""
14
+
15
+
16
+ class LSPHandshakeError(LSPError):
17
+ """Language server initialize/start handshake failed: timeout, multilspy
18
+ refused to launch, or the underlying server crashed during startup."""
19
+
20
+
21
+ class LSPCallError(LSPError):
22
+ """A ``request_*`` call (definition / references / outline / diagnostics)
23
+ raised inside multilspy. Handlers catch this and return ``str(exc)`` to the
24
+ LLM."""