codelith 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 (129) hide show
  1. backend/__init__.py +1 -0
  2. backend/agents/__init__.py +6 -0
  3. backend/agents/assessment_agent.py +273 -0
  4. backend/agents/coding_agent.py +779 -0
  5. backend/agents/concept_categories.py +131 -0
  6. backend/agents/concept_detector.py +1217 -0
  7. backend/agents/debug_agent.py +166 -0
  8. backend/agents/teacher_agent.py +179 -0
  9. backend/cli/__init__.py +1 -0
  10. backend/cli/config_cmd.py +135 -0
  11. backend/cli/main.py +606 -0
  12. backend/daemon/__init__.py +1 -0
  13. backend/daemon/launcher.py +243 -0
  14. backend/daemon/server.py +453 -0
  15. backend/daemon/state.py +110 -0
  16. backend/daemon/static/assets/Gambarino-Regular-BjbcsURA.otf +0 -0
  17. backend/daemon/static/assets/abnfDiagram-VCTEODGH-CCJBE2aE.js +1 -0
  18. backend/daemon/static/assets/arc-BEvzHx4o.js +1 -0
  19. backend/daemon/static/assets/architecture-7GRP2DOG-DaWrPggL.js +1 -0
  20. backend/daemon/static/assets/architectureDiagram-5GKGNRK7-pR-klcZv.js +36 -0
  21. backend/daemon/static/assets/array-BifhSqXX.js +1 -0
  22. backend/daemon/static/assets/blockDiagram-I7D4REHJ-C504Gj6_.js +129 -0
  23. backend/daemon/static/assets/c4Diagram-7LVT6UL2-BjM04Mni.js +38 -0
  24. backend/daemon/static/assets/channel-DzSauwD3.js +1 -0
  25. backend/daemon/static/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  26. backend/daemon/static/assets/chunk-4HAMMTFA-EgoP78tp.js +62 -0
  27. backend/daemon/static/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  28. backend/daemon/static/assets/chunk-75Z2AOVW-EXNbuzun.js +2 -0
  29. backend/daemon/static/assets/chunk-DU6HZSFF-CF3OK3MZ.js +127 -0
  30. backend/daemon/static/assets/chunk-F27PBJKO-G71ylWJa.js +1 -0
  31. backend/daemon/static/assets/chunk-FOHPRMQF-DHwB1DNv.js +161 -0
  32. backend/daemon/static/assets/chunk-GMAD6QVW-2yfGg28o.js +72 -0
  33. backend/daemon/static/assets/chunk-GVQU2GXP-C_VeaX4U.js +1 -0
  34. backend/daemon/static/assets/chunk-IMKFNOWR-CNexRjjn.js +231 -0
  35. backend/daemon/static/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  36. backend/daemon/static/assets/chunk-P2QGCYS3-E4AByfsD.js +1 -0
  37. backend/daemon/static/assets/chunk-POPQ4Y6H-Bisbc2-3.js +1 -0
  38. backend/daemon/static/assets/chunk-PWAF6VOD-DaoPxZAa.js +1 -0
  39. backend/daemon/static/assets/chunk-SHT3W25Y-DarPToto.js +168 -0
  40. backend/daemon/static/assets/chunk-SVP7TREG-DvMOAiwI.js +88 -0
  41. backend/daemon/static/assets/chunk-TICWLB2K-DheuvyGM.js +206 -0
  42. backend/daemon/static/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
  43. backend/daemon/static/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  44. backend/daemon/static/assets/classDiagram-ZZMXUADV-Ys5zkCXW.js +1 -0
  45. backend/daemon/static/assets/classDiagram-v2-VYDZK3BY-Ys5zkCXW.js +1 -0
  46. backend/daemon/static/assets/cose-bilkent-JH36ORCC-DLPLnxrP.js +1 -0
  47. backend/daemon/static/assets/cynefin-OW5HDTMX-Dv1OY_0y.js +1 -0
  48. backend/daemon/static/assets/cynefinDiagram-5FMLGOSQ-Ur7MTCmF.js +62 -0
  49. backend/daemon/static/assets/cytoscape.esm-CECbKnxF.js +321 -0
  50. backend/daemon/static/assets/dagre-CJLTJMFW.js +1 -0
  51. backend/daemon/static/assets/dagre-GXQ25YYZ-R3BwTvng.js +4 -0
  52. backend/daemon/static/assets/defaultLocale-BFoDCU3G.js +1 -0
  53. backend/daemon/static/assets/diagram-S7CK7UJ4-BxIoEKb4.js +30 -0
  54. backend/daemon/static/assets/diagram-UQ7AKVKN-DO4cuWN-.js +41 -0
  55. backend/daemon/static/assets/diagram-VSXAHHWV-DW5imp5t.js +3 -0
  56. backend/daemon/static/assets/diagram-VX7I27RA-CdZ3k7wQ.js +24 -0
  57. backend/daemon/static/assets/diagram-Z3DM3KII-DPyjbneL.js +24 -0
  58. backend/daemon/static/assets/dist-DTg6UBE_.js +1 -0
  59. backend/daemon/static/assets/ebnfDiagram-PWID7BFC-BO7VQsye.js +1 -0
  60. backend/daemon/static/assets/erDiagram-RLTQ6QDP-CevvjECq.js +99 -0
  61. backend/daemon/static/assets/eventmodeling-NTZA5JFV-yNfKR6-v.js +1 -0
  62. backend/daemon/static/assets/flowDiagram-HODETNUW-B4GT41mU.js +1 -0
  63. backend/daemon/static/assets/ganttDiagram-EL5Y4UJY-DNW5fWw1.js +292 -0
  64. backend/daemon/static/assets/gitGraph-4MIJSDKK-DKgVkWaZ.js +1 -0
  65. backend/daemon/static/assets/gitGraphDiagram-WWUBYQGX-0S7OF9Aj.js +106 -0
  66. backend/daemon/static/assets/index-D3vj8REa.js +63 -0
  67. backend/daemon/static/assets/index-D4lMFaiv.css +1 -0
  68. backend/daemon/static/assets/info-A6RAGUB7-Bxy-SzRN.js +1 -0
  69. backend/daemon/static/assets/infoDiagram-27XIBGKW-ClzQji6X.js +2 -0
  70. backend/daemon/static/assets/init-C-OQMol4.js +1 -0
  71. backend/daemon/static/assets/ishikawaDiagram-5VMMS53U-B3Lo-sS3.js +70 -0
  72. backend/daemon/static/assets/journeyDiagram-3NMN7TZE-0KL6R2Rz.js +139 -0
  73. backend/daemon/static/assets/kanban-definition-UXKFOSKX-zt5NbEep.js +89 -0
  74. backend/daemon/static/assets/katex-CXMH3UgJ.js +257 -0
  75. backend/daemon/static/assets/line-CiAFRJVJ.js +1 -0
  76. backend/daemon/static/assets/linear-BI6yqEPV.js +1 -0
  77. backend/daemon/static/assets/logo_darkmode-BPDdj6GZ.png +0 -0
  78. backend/daemon/static/assets/logo_lightmode-C3ZWMgAH.png +0 -0
  79. backend/daemon/static/assets/mermaid-parser.core-DEadI1Ja.js +7 -0
  80. backend/daemon/static/assets/mindmap-definition-YA3MSWOX-TGKGYg5n.js +96 -0
  81. backend/daemon/static/assets/ordinal-BDEzSJ7C.js +1 -0
  82. backend/daemon/static/assets/packet-AYTQ26CC-CZTSuh5x.js +1 -0
  83. backend/daemon/static/assets/path-fybaL0A-.js +1 -0
  84. backend/daemon/static/assets/pegDiagram-XKGWAZYB-DGd8LACA.js +1 -0
  85. backend/daemon/static/assets/pie-WAS4IAKB-B59sPr3Z.js +1 -0
  86. backend/daemon/static/assets/pieDiagram-E7YTZNPT-CpwxCR3L.js +39 -0
  87. backend/daemon/static/assets/quadrantDiagram-AXDQQJYC-BwSeF_E_.js +7 -0
  88. backend/daemon/static/assets/radar-RG4KPBEZ-DAa4JvTb.js +1 -0
  89. backend/daemon/static/assets/railroad-74A4TZTK-BitdNgDt.js +1 -0
  90. backend/daemon/static/assets/railroad-abnf-HS5TGJTU-DCrNKqAH.js +1 -0
  91. backend/daemon/static/assets/railroad-ebnf-LZEXJU2U-DmEwx8OK.js +1 -0
  92. backend/daemon/static/assets/railroad-peg-WCYAUIDC-CPc8dTCP.js +1 -0
  93. backend/daemon/static/assets/railroadDiagram-O6MQD6OU-DuizuzwD.js +1 -0
  94. backend/daemon/static/assets/requirementDiagram-BXWQKSXE-BjMk0yS8.js +84 -0
  95. backend/daemon/static/assets/rough.esm-Dy-Kn_BL.js +1 -0
  96. backend/daemon/static/assets/sankeyDiagram-P5KCCOFB-0T_bhkmz.js +40 -0
  97. backend/daemon/static/assets/sequenceDiagram-WJ2MYXX4-Cwa-1Stp.js +162 -0
  98. backend/daemon/static/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
  99. backend/daemon/static/assets/src-BH-TyZbA.js +1 -0
  100. backend/daemon/static/assets/stateDiagram-D77RDMKH-BpQSg_QL.js +1 -0
  101. backend/daemon/static/assets/stateDiagram-v2-MP3YSRHH-BItVXKof.js +1 -0
  102. backend/daemon/static/assets/swimlanes-42K2YHIH-h_ED18Vy.js +1 -0
  103. backend/daemon/static/assets/swimlanesDiagram-VR7AAH4N-D0fo0LN-.js +8 -0
  104. backend/daemon/static/assets/timeline-definition-24CTP7MA-DKfSO33a.js +120 -0
  105. backend/daemon/static/assets/treeView-Q6P3EWNA-DAj9fxfC.js +1 -0
  106. backend/daemon/static/assets/treemap-WGGIJYW6-5IIXD9Zu.js +1 -0
  107. backend/daemon/static/assets/vennDiagram-4TSXK5OY-BoBvVEci.js +34 -0
  108. backend/daemon/static/assets/wardley-WFR3VGLG-CGsd7s_-.js +1 -0
  109. backend/daemon/static/assets/wardleyDiagram-VM6X3IG4-QHdK5NsY.js +78 -0
  110. backend/daemon/static/assets/xychartDiagram-S5SC5T6Z-MN_fdKCJ.js +7 -0
  111. backend/daemon/static/index.html +49 -0
  112. backend/database/__init__.py +1 -0
  113. backend/database/concept_slug.py +39 -0
  114. backend/database/concepts.py +804 -0
  115. backend/llm/__init__.py +5 -0
  116. backend/llm/client.py +333 -0
  117. backend/llm/config.py +254 -0
  118. backend/llm/key_setup.py +237 -0
  119. backend/main.py +13 -0
  120. backend/orchestrator/__init__.py +1 -0
  121. backend/orchestrator/events.py +52 -0
  122. backend/orchestrator/graph.py +316 -0
  123. backend/orchestrator/modes.py +125 -0
  124. codelith-0.1.0.dist-info/METADATA +301 -0
  125. codelith-0.1.0.dist-info/RECORD +129 -0
  126. codelith-0.1.0.dist-info/WHEEL +5 -0
  127. codelith-0.1.0.dist-info/entry_points.txt +2 -0
  128. codelith-0.1.0.dist-info/licenses/LICENSE +21 -0
  129. codelith-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,779 @@
1
+ """Coding Agent — reads and writes files to help users with code.
2
+
3
+ This agent can read files from the user's project to explain code and
4
+ answer questions, and can write or edit files when asked to create,
5
+ modify, or refactor code. Future phases will add command execution
6
+ and more advanced capabilities.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
20
+
21
+ from backend.llm.client import (
22
+ AGENT_MAX_TOKENS,
23
+ resolve_agent_api_key,
24
+ resolve_agent_model, # noqa: F401 (re-exported for backwards compat)
25
+ get_agent_client,
26
+ )
27
+ from backend.llm.config import get_model
28
+ from backend.orchestrator.events import emit_event
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # System prompt
32
+ # ---------------------------------------------------------------------------
33
+
34
+ SYSTEM_PROMPT = """You are CodeLith, a coding agent. Use tools to read, write, edit files and run commands. ALWAYS use tools, never describe code. Call multiple tools in sequence. Keep final replies short (1-2 sentences).
35
+
36
+ When asked to run or serve an app:
37
+ - For static HTML/CSS/JS files: run `python -m http.server 8080` in the file's directory.
38
+ - For Node.js projects: run `npm run dev` or `npx serve`.
39
+ - For Python web apps: run `uvicorn <module>:app --reload`.
40
+ - Always tell the user the URL (e.g. http://localhost:8080).
41
+ - Long-running servers start in the background automatically."""
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Tool definitions (Groq / OpenAI function-calling format)
45
+ # ---------------------------------------------------------------------------
46
+
47
+ TOOL_DEFINITIONS = [
48
+ {
49
+ "type": "function",
50
+ "function": {
51
+ "name": "read_file",
52
+ "description": "Read a file's contents. file_path is relative to the workspace root.",
53
+ "parameters": {
54
+ "type": "object",
55
+ "properties": {
56
+ "file_path": {"type": "string"},
57
+ },
58
+ "required": ["file_path"],
59
+ "additionalProperties": False,
60
+ },
61
+ },
62
+ },
63
+ {
64
+ "type": "function",
65
+ "function": {
66
+ "name": "write_file",
67
+ "description": "Create or overwrite a file. file_path is RELATIVE to the workspace root; parent directories are created automatically.",
68
+ "parameters": {
69
+ "type": "object",
70
+ "properties": {
71
+ "file_path": {"type": "string"},
72
+ "content": {"type": "string"},
73
+ },
74
+ "required": ["file_path", "content"],
75
+ "additionalProperties": False,
76
+ },
77
+ },
78
+ },
79
+ {
80
+ "type": "function",
81
+ "function": {
82
+ "name": "edit_file",
83
+ "description": "Replace text in a file. file_path is relative to the workspace root.",
84
+ "parameters": {
85
+ "type": "object",
86
+ "properties": {
87
+ "file_path": {"type": "string"},
88
+ "old_string": {"type": "string"},
89
+ "new_string": {"type": "string"},
90
+ },
91
+ "required": ["file_path", "old_string", "new_string"],
92
+ "additionalProperties": False,
93
+ },
94
+ },
95
+ },
96
+ {
97
+ "type": "function",
98
+ "function": {
99
+ "name": "run_command",
100
+ "description": "Execute a shell command with the workspace root as the working directory. Must be valid for the machine's shell (see ENVIRONMENT).",
101
+ "parameters": {
102
+ "type": "object",
103
+ "properties": {
104
+ "command": {"type": "string"},
105
+ },
106
+ "required": ["command"],
107
+ "additionalProperties": False,
108
+ },
109
+ },
110
+ }
111
+ ]
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Tool execution
115
+ # ---------------------------------------------------------------------------
116
+
117
+ MAX_FILE_SIZE = 100_000 # bytes – guard against huge files
118
+ MAX_WRITE_SIZE = 500_000 # bytes – guard against oversized writes
119
+ COMMAND_TIMEOUT = 60 # seconds – prevent hanging commands
120
+ BACKGROUND_COMMANDS = {
121
+ "python -m http.server",
122
+ "npx serve",
123
+ "npx http-server",
124
+ "node server",
125
+ "npm run dev",
126
+ "yarn dev",
127
+ "pnpm dev",
128
+ "vite",
129
+ "uvicorn",
130
+ }
131
+ MAX_OUTPUT_SIZE = 50_000 # chars – guard against huge command output
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Path resolution — one sandboxed resolver shared by all file tools
136
+ # ---------------------------------------------------------------------------
137
+
138
+ def _path_error(file_path: str, root: Path) -> str:
139
+ """Actionable rejection: name the root and how to address files in it."""
140
+ return (
141
+ f"Error: path '{file_path}' is outside the workspace. "
142
+ f"The workspace root is {root}. Pass file_path RELATIVE to the "
143
+ "workspace root, e.g. 'index.html' or 'my-folder/index.html'."
144
+ )
145
+
146
+
147
+ def _resolve_in_workspace(file_path: str, workspace_root: str) -> tuple[Path | None, str | None]:
148
+ """Resolve *file_path* to a real path inside *workspace_root*.
149
+
150
+ Returns ``(path, None)`` on success or ``(None, error)`` when the path
151
+ cannot be sandboxed. Models regularly hallucinate host-specific paths
152
+ (``/home/user/...``, ``C:/Users/<someone>/...``, ``~/...``) because they
153
+ are never told the workspace location, so the resolver forgives what it
154
+ can:
155
+
156
+ - ``~/...`` and POSIX-style ``/...`` paths are rebased into the
157
+ workspace as relative paths;
158
+ - surrounding quotes and whitespace are stripped;
159
+ - true Windows absolute paths (``C:/...``) are kept only when they
160
+ already point inside the workspace.
161
+
162
+ Every rejection message names the workspace root so the model can
163
+ self-correct within the same turn instead of thrashing across rounds.
164
+ """
165
+ raw = (file_path or "").strip().strip('"').strip("'").strip()
166
+ if not raw:
167
+ return None, "Error: file path must not be empty."
168
+
169
+ root = Path(workspace_root).resolve()
170
+
171
+ if raw.startswith("~"):
172
+ # ``~/x`` or ``~\x`` — the agent cannot see the user's home, so
173
+ # interpret it as workspace-relative.
174
+ target = root / raw[1:].lstrip("/\\")
175
+ else:
176
+ p = Path(raw)
177
+ if p.is_absolute() or raw.startswith(("/", "\\")):
178
+ if p.drive:
179
+ # Real Windows absolute path (C:\...) — keep it; the
180
+ # containment check below decides whether it is allowed.
181
+ target = p
182
+ else:
183
+ # POSIX-style root path (/home/user/...) — the driveless
184
+ # anchor is '/', so strip it and rebase into the workspace
185
+ # instead of rejecting outright.
186
+ target = root / p.relative_to(p.anchor)
187
+ else:
188
+ target = root / p
189
+
190
+ try:
191
+ target = target.resolve()
192
+ except (OSError, ValueError) as exc:
193
+ return None, f"Error resolving path: {exc}"
194
+
195
+ if not target.is_relative_to(root):
196
+ return None, _path_error(raw, root)
197
+ return target, None
198
+
199
+
200
+ def _shell_context() -> str:
201
+ """One-line description of the shell run_command executes in."""
202
+ if os.name == "nt":
203
+ return (
204
+ "Windows cmd.exe. Use cmd syntax: `mkdir <dir>` (never "
205
+ "`mkdir -p`), `type <file>` to print a file, `del` to delete."
206
+ )
207
+ return "POSIX bash — standard Unix commands work."
208
+
209
+
210
+ def workspace_context(workspace_root: str) -> str:
211
+ """ENVIRONMENT block appended to agent prompts.
212
+
213
+ The agent previously had no idea where it was running, so it invented
214
+ ``/home/user/...`` paths (rejected by the sandbox) and Unix flags on
215
+ cmd.exe (creating junk folders). This block grounds every call.
216
+ """
217
+ return (
218
+ "\n\n## ENVIRONMENT\n"
219
+ f"- Workspace root (the ONLY directory you may read or write): "
220
+ f"{Path(workspace_root).resolve()}\n"
221
+ "- ALWAYS pass file_path RELATIVE to the workspace root, e.g. "
222
+ "'index.html' or 'himani-website/index.html'. NEVER invent "
223
+ "absolute paths, usernames, or /home/... paths.\n"
224
+ f"- Shell: {_shell_context()}\n"
225
+ "- Commands run with the workspace root as the working directory; "
226
+ "do not cd outside it.\n"
227
+ )
228
+
229
+
230
+ def _read_file(file_path: str, workspace_root: str) -> str:
231
+ """Read *file_path* relative to *workspace_root*.
232
+
233
+ Returns the file contents as a string, or an error message if the file
234
+ cannot be read.
235
+ """
236
+ target, error = _resolve_in_workspace(file_path, workspace_root)
237
+ if error:
238
+ return error
239
+ assert target is not None # for type checkers
240
+
241
+ try:
242
+ size = target.stat().st_size
243
+ if size > MAX_FILE_SIZE:
244
+ return (
245
+ f"Error: file is {size:,} bytes, exceeding the "
246
+ f"{MAX_FILE_SIZE:,} byte limit."
247
+ )
248
+ return target.read_text(encoding="utf-8", errors="replace")
249
+ except FileNotFoundError:
250
+ return f"Error: file not found: {file_path}"
251
+ except IsADirectoryError:
252
+ return f"Error: '{file_path}' is a directory, not a file."
253
+ except OSError as exc:
254
+ return f"Error reading file: {exc}"
255
+
256
+
257
+ def _write_file(file_path: str, content: str, workspace_root: str) -> str:
258
+ """Write *content* to *file_path* relative to *workspace_root*.
259
+
260
+ Creates parent directories as needed. Returns a confirmation message
261
+ or an error string.
262
+ """
263
+ if len(content.encode("utf-8")) > MAX_WRITE_SIZE:
264
+ return (
265
+ f"Error: content is too large ({len(content):,} chars), "
266
+ f"exceeding the {MAX_WRITE_SIZE:,} byte limit."
267
+ )
268
+
269
+ target, error = _resolve_in_workspace(file_path, workspace_root)
270
+ if error:
271
+ return error
272
+ assert target is not None # for type checkers
273
+
274
+ try:
275
+ target.parent.mkdir(parents=True, exist_ok=True)
276
+ target.write_text(content, encoding="utf-8")
277
+ except OSError as exc:
278
+ return f"Error writing file: {exc}"
279
+ try:
280
+ shown = target.relative_to(Path(workspace_root).resolve()).as_posix()
281
+ except ValueError:
282
+ shown = str(target)
283
+ return f"Successfully wrote {len(content):,} chars to {shown}"
284
+
285
+
286
+ def _edit_file(
287
+ file_path: str,
288
+ old_string: str,
289
+ new_string: str,
290
+ workspace_root: str,
291
+ ) -> str:
292
+ """Replace *old_string* with *new_string* in *file_path*.
293
+
294
+ All occurrences of *old_string* are replaced. Returns a confirmation
295
+ message with the number of replacements, or an error string.
296
+ """
297
+ if not old_string:
298
+ return "Error: old_string must not be empty."
299
+
300
+ target, error = _resolve_in_workspace(file_path, workspace_root)
301
+ if error:
302
+ return error
303
+ assert target is not None # for type checkers
304
+
305
+ try:
306
+ original = target.read_text(encoding="utf-8", errors="replace")
307
+ except FileNotFoundError:
308
+ return f"Error: file not found: {file_path}"
309
+ except IsADirectoryError:
310
+ return f"Error: '{file_path}' is a directory, not a file."
311
+ except OSError as exc:
312
+ return f"Error reading file: {exc}"
313
+
314
+ if old_string not in original:
315
+ return (
316
+ f"Error: old_string not found in {file_path}. "
317
+ "Use read_file to get the exact contents first."
318
+ )
319
+
320
+ count = original.count(old_string)
321
+ updated = original.replace(old_string, new_string)
322
+
323
+ if len(updated.encode("utf-8")) > MAX_WRITE_SIZE:
324
+ return (
325
+ f"Error: result would be too large "
326
+ f"({len(updated):,} chars), exceeding the "
327
+ f"{MAX_WRITE_SIZE:,} byte limit."
328
+ )
329
+
330
+ try:
331
+ target.write_text(updated, encoding="utf-8")
332
+ except OSError as exc:
333
+ return f"Error writing file: {exc}"
334
+ try:
335
+ shown = target.relative_to(Path(workspace_root).resolve()).as_posix()
336
+ except ValueError:
337
+ shown = str(target)
338
+ return f"Successfully replaced {count} occurrence(s) in {shown}."
339
+
340
+
341
+ def _is_background_command(command: str) -> bool:
342
+ """Return True if *command* looks like a long-running server process."""
343
+ cmd_lower = command.lower().strip()
344
+ return any(bg in cmd_lower for bg in BACKGROUND_COMMANDS)
345
+
346
+
347
+ def _run_command(
348
+ command: str, workspace_root: str
349
+ ) -> tuple[str, int, bool]:
350
+ """Execute *command* in *workspace_root* and return structured results.
351
+
352
+ Returns ``(output, exit_code, stderr_present)``. ``exit_code`` is 0
353
+ on success, ``stderr_present`` is True when the command wrote anything
354
+ to stderr. Long-running commands (HTTP servers, dev servers) are
355
+ started in the background so they don't block. Other commands run
356
+ with a timeout.
357
+ """
358
+ if not command.strip():
359
+ return "Error: command must not be empty.", 1, True
360
+
361
+ # Background long-running commands
362
+ if _is_background_command(command):
363
+ try:
364
+ proc = subprocess.Popen(
365
+ command,
366
+ shell=True,
367
+ cwd=workspace_root,
368
+ stdout=subprocess.PIPE,
369
+ stderr=subprocess.PIPE,
370
+ )
371
+ time.sleep(2)
372
+ if proc.poll() is not None:
373
+ stdout, stderr = proc.communicate(timeout=5)
374
+ output = ""
375
+ if stdout:
376
+ output += stdout.decode("utf-8", errors="replace")
377
+ if stderr:
378
+ output += ("\n" if output else "") + stderr.decode("utf-8", errors="replace")
379
+ if not output:
380
+ output = "(no output)"
381
+ return output + f"\n(exit code: {proc.returncode})", proc.returncode or 0, bool(stderr)
382
+ else:
383
+ return (
384
+ f"Server started in background (pid: {proc.pid}). "
385
+ f"It will keep running until you stop it.",
386
+ 0,
387
+ False,
388
+ )
389
+ except OSError as exc:
390
+ return f"Error starting background process: {exc}", 1, True
391
+
392
+ # Regular commands with timeout
393
+ try:
394
+ result = subprocess.run(
395
+ command,
396
+ shell=True,
397
+ cwd=workspace_root,
398
+ capture_output=True,
399
+ text=True,
400
+ timeout=COMMAND_TIMEOUT,
401
+ )
402
+ except subprocess.TimeoutExpired:
403
+ return (
404
+ f"Error: command timed out after {COMMAND_TIMEOUT}s. "
405
+ "The command may be hanging.",
406
+ 124,
407
+ True,
408
+ )
409
+ except OSError as exc:
410
+ return f"Error running command: {exc}", 1, True
411
+
412
+ # Combine stdout and stderr.
413
+ output = ""
414
+ if result.stdout:
415
+ output += result.stdout
416
+ if result.stderr:
417
+ output += ("\n" if output else "") + result.stderr
418
+
419
+ if not output:
420
+ output = "(no output)"
421
+
422
+ # Truncate if too large.
423
+ if len(output) > MAX_OUTPUT_SIZE:
424
+ output = output[:MAX_OUTPUT_SIZE] + "\n... (output truncated)"
425
+
426
+ # Append exit code.
427
+ output += f"\n(exit code: {result.returncode})"
428
+ return output, result.returncode, bool(result.stderr)
429
+
430
+
431
+ MAX_TOOL_ROUNDS = 15 # prevent infinite loops
432
+
433
+
434
+ def _parse_tool_args(raw_arguments: str) -> dict[str, Any]:
435
+ """Parse a tool-call arguments JSON string, returning {} on failure."""
436
+ try:
437
+ parsed = json.loads(raw_arguments)
438
+ return parsed if isinstance(parsed, dict) else {}
439
+ except (json.JSONDecodeError, TypeError):
440
+ return {}
441
+
442
+
443
+ MAX_LLM_RETRIES = 2 # extra attempts after the first provider-side failure
444
+
445
+ RETRY_NUDGE_PROMPT = (
446
+ "Your previous tool call could not be parsed: its JSON arguments were "
447
+ "malformed or used parameters outside the tool schema. Re-emit the same "
448
+ "tool call with strictly valid JSON arguments, using only the defined "
449
+ "parameters."
450
+ )
451
+
452
+
453
+ def _is_provider_tool_use_error(exc: Exception) -> bool:
454
+ """True for provider-side failures caused by the model's own bad
455
+ tool-call generation (e.g. Groq's ``tool_use_failed`` / HTTP 400).
456
+
457
+ These are worth retrying: regenerating the tool call almost always
458
+ produces valid JSON, unlike network or auth errors.
459
+ """
460
+ text = str(exc)
461
+ return "tool_use_failed" in text or "Failed to parse tool call arguments" in text
462
+
463
+
464
+ def _create_with_retry(client: Any, api_messages: list[dict[str, Any]]) -> Any:
465
+ """Call the chat completion API, retrying provider-side tool-use errors.
466
+
467
+ Groq rejects malformed tool-call generations with HTTP 400
468
+ ``tool_use_failed`` instead of returning them. A nudge message is
469
+ appended before each retry (on a local copy of the conversation, so
470
+ the real history stays clean) telling the model what went wrong.
471
+ Any non-retryable error, or retries exhausted, raises.
472
+ """
473
+ attempt_messages = list(api_messages)
474
+ for attempt in range(MAX_LLM_RETRIES + 1):
475
+ try:
476
+ return client.chat.completions.create(
477
+ model=get_model("coding"),
478
+ messages=attempt_messages,
479
+ tools=TOOL_DEFINITIONS,
480
+ max_tokens=AGENT_MAX_TOKENS,
481
+ )
482
+ except Exception as exc:
483
+ if attempt >= MAX_LLM_RETRIES or not _is_provider_tool_use_error(exc):
484
+ raise
485
+ attempt_messages = attempt_messages + [
486
+ {"role": "user", "content": RETRY_NUDGE_PROMPT}
487
+ ]
488
+ raise RuntimeError("unreachable")
489
+
490
+
491
+ def _describe_tool(name: str, args: dict[str, Any]) -> str:
492
+ """Return a short human-readable detail line for a tool call."""
493
+ if name in ("read_file", "write_file", "edit_file"):
494
+ return str(args.get("file_path", ""))
495
+ if name == "run_command":
496
+ return str(args.get("command", ""))
497
+ return json.dumps(args)[:120]
498
+
499
+
500
+ def _execute_tool(name: str, args: dict[str, Any], workspace_root: str) -> str:
501
+ """Execute a single tool by name and return its result string."""
502
+ if name == "read_file":
503
+ return _read_file(args.get("file_path", ""), workspace_root)
504
+ if name == "write_file":
505
+ return _write_file(
506
+ args.get("file_path", ""),
507
+ args.get("content", ""),
508
+ workspace_root,
509
+ )
510
+ if name == "edit_file":
511
+ return _edit_file(
512
+ args.get("file_path", ""),
513
+ args.get("old_string", ""),
514
+ args.get("new_string", ""),
515
+ workspace_root,
516
+ )
517
+ if name == "run_command":
518
+ return _run_command(args.get("command", ""), workspace_root)
519
+ return f"Error: unknown tool '{name}'."
520
+
521
+
522
+ # ---------------------------------------------------------------------------
523
+ # Text-based tool extraction fallback
524
+ # ---------------------------------------------------------------------------
525
+
526
+ def _extract_tool_calls_from_text(text: str) -> list[dict[str, Any]]:
527
+ """Parse tool calls from plain text when the model doesn't use the tool API.
528
+
529
+ Looks for patterns like:
530
+ tool_name(arg1, arg2)
531
+ tool_name("arg1", "arg2")
532
+ """
533
+ tool_calls: list[dict[str, Any]] = []
534
+
535
+ # Match patterns like: function_name(args)
536
+ pattern = r'\b(read_file|write_file|edit_file|run_command)\s*\((.+?)\)\s*(?:\n|$)'
537
+ matches = re.findall(pattern, text, re.DOTALL)
538
+
539
+ for tool_name, raw_args in matches:
540
+ # Parse arguments
541
+ args_str = raw_args.strip()
542
+ try:
543
+ # Try JSON-style first
544
+ if args_str.startswith('{'):
545
+ args = json.loads(args_str)
546
+ elif '"' in args_str or "'" in args_str:
547
+ # Extract quoted strings
548
+ parts = re.findall(r'["\'](.+?)["\']', args_str)
549
+ if tool_name == 'read_file' and parts:
550
+ args = {'file_path': parts[0]}
551
+ elif tool_name == 'write_file' and len(parts) >= 2:
552
+ args = {'file_path': parts[0], 'content': parts[1]}
553
+ elif tool_name == 'edit_file' and len(parts) >= 3:
554
+ args = {'file_path': parts[0], 'old_string': parts[1], 'new_string': parts[2]}
555
+ elif tool_name == 'run_command' and parts:
556
+ args = {'command': parts[0]}
557
+ else:
558
+ args = {}
559
+ else:
560
+ # Bare arguments
561
+ if tool_name in ('read_file', 'run_command'):
562
+ args = {'file_path': args_str} if tool_name == 'read_file' else {'command': args_str}
563
+ else:
564
+ args = {}
565
+ except (json.JSONDecodeError, TypeError):
566
+ args = {}
567
+
568
+ tool_calls.append({
569
+ 'function': {
570
+ 'name': tool_name,
571
+ 'arguments': json.dumps(args),
572
+ }
573
+ })
574
+
575
+ return tool_calls
576
+
577
+
578
+ # ---------------------------------------------------------------------------
579
+ # Agent node
580
+ # ---------------------------------------------------------------------------
581
+
582
+
583
+ def coding_agent_node(state: dict[str, Any]) -> dict[str, Any]:
584
+ """LangGraph node: take the latest user message, optionally use tools,
585
+ and produce an AI reply.
586
+
587
+ Expects ``state["messages"]`` to be a list of ``BaseMessage`` instances.
588
+ Expects ``state["workspace_root"]`` to be a string path to the project root.
589
+ Returns a dict with the new ``messages`` list (appended AI reply).
590
+ """
591
+ messages: list[BaseMessage] = state.get("messages", [])
592
+ workspace_root: str = state.get("workspace_root", os.getcwd())
593
+
594
+ # Mode-specific behavior: system prompt suffix and tool-round budget.
595
+ mode_config: dict[str, Any] = state.get("current_mode_config") or {}
596
+ system_prompt = (
597
+ SYSTEM_PROMPT
598
+ + mode_config.get("prompt_suffix", "")
599
+ + workspace_context(workspace_root)
600
+ )
601
+ max_tool_rounds = int(mode_config.get("max_tool_rounds", MAX_TOOL_ROUNDS))
602
+
603
+ api_key = resolve_agent_api_key()
604
+ if not api_key:
605
+ reply = (
606
+ "I need an OpenRouter API key to code. Set the OPENROUTER_API_KEY "
607
+ "environment variable, or add it to a .env file in the project "
608
+ "root, then try again."
609
+ )
610
+ return {"messages": messages + [AIMessage(content=reply)]}
611
+
612
+ try:
613
+ client = get_agent_client()
614
+ except ValueError as exc:
615
+ return {"messages": messages + [AIMessage(content=str(exc))]}
616
+
617
+ # Build the conversation.
618
+ api_messages: list[dict[str, Any]] = [
619
+ {"role": "system", "content": system_prompt}
620
+ ]
621
+ for msg in messages:
622
+ if isinstance(msg, HumanMessage):
623
+ api_messages.append({"role": "user", "content": msg.content})
624
+ elif isinstance(msg, AIMessage):
625
+ api_messages.append({"role": "assistant", "content": msg.content})
626
+
627
+ # Collect tool calls for the teacher agent to analyze
628
+ all_tool_calls: list[dict[str, Any]] = []
629
+
630
+ # Tool-use loop: keep calling the LLM until it produces a text reply
631
+ # (no more tool calls) or we hit the round limit.
632
+ for _ in range(max_tool_rounds):
633
+ emit_event("status", message="Thinking…")
634
+ try:
635
+ completion = _create_with_retry(client, api_messages)
636
+ except Exception as exc:
637
+ reply_text = f"(LLM error: {exc})"
638
+ return {
639
+ "messages": messages + [AIMessage(content=reply_text)],
640
+ "tool_calls_log": all_tool_calls,
641
+ # Provider-side failure, not a code problem — lets the
642
+ # orchestrator skip the debug agent (see graph.py).
643
+ "llm_error": True,
644
+ }
645
+
646
+ choice = completion.choices[0]
647
+ message = choice.message
648
+
649
+ # Track tool calls for the teacher agent. run_command entries are
650
+ # appended at execution time instead, so they can carry the
651
+ # structured exit_code / stderr_present fields the debug router
652
+ # reads (see backend/orchestrator/graph.py::_route_after_coding).
653
+ if message.tool_calls:
654
+ for tc in message.tool_calls:
655
+ if tc.function.name == "run_command":
656
+ continue
657
+ all_tool_calls.append({
658
+ "function": {
659
+ "name": tc.function.name,
660
+ "arguments": tc.function.arguments,
661
+ }
662
+ })
663
+
664
+ # If the model returned tool calls, execute them and continue the loop.
665
+ if message.tool_calls:
666
+ # Append the assistant message (with tool calls) to the conversation.
667
+ # Strip fields unsupported by Groq (e.g. 'annotations').
668
+ assistant_msg: dict[str, Any] = {
669
+ "role": "assistant",
670
+ "content": message.content,
671
+ "tool_calls": [
672
+ {
673
+ "id": tc.id,
674
+ "type": "function",
675
+ "function": {
676
+ "name": tc.function.name,
677
+ "arguments": tc.function.arguments,
678
+ },
679
+ }
680
+ for tc in message.tool_calls
681
+ ],
682
+ }
683
+ api_messages.append(assistant_msg)
684
+
685
+ for tool_call in message.tool_calls:
686
+ fn = tool_call.function
687
+ args = _parse_tool_args(fn.arguments)
688
+ if not args:
689
+ # The model emitted a tool call with unparseable
690
+ # arguments — report an error for this call id (the
691
+ # API requires a tool message per call) so the next
692
+ # round regenerates it instead of executing with
693
+ # empty arguments.
694
+ emit_event(
695
+ "tool_done",
696
+ tool=fn.name,
697
+ detail="unparseable arguments",
698
+ ok=False,
699
+ )
700
+ api_messages.append(
701
+ {
702
+ "role": "tool",
703
+ "tool_call_id": tool_call.id,
704
+ "content": (
705
+ f"Error: could not parse arguments for "
706
+ f"'{fn.name}' as JSON. Re-emit the tool "
707
+ f"call with valid JSON arguments."
708
+ ),
709
+ }
710
+ )
711
+ continue
712
+ detail = _describe_tool(fn.name, args)
713
+ emit_event("tool_start", tool=fn.name, detail=detail)
714
+ result = _execute_tool(fn.name, args, workspace_root)
715
+ if fn.name == "run_command" and isinstance(result, tuple):
716
+ result, cmd_exit_code, cmd_stderr_present = result
717
+ log_entry = {
718
+ "function": {
719
+ "name": fn.name,
720
+ "arguments": fn.arguments,
721
+ },
722
+ "exit_code": cmd_exit_code,
723
+ "stderr_present": cmd_stderr_present,
724
+ }
725
+ all_tool_calls.append(log_entry)
726
+ emit_event(
727
+ "tool_done",
728
+ tool=fn.name,
729
+ detail=detail,
730
+ ok=not str(result).startswith("Error"),
731
+ )
732
+
733
+ api_messages.append(
734
+ {
735
+ "role": "tool",
736
+ "tool_call_id": tool_call.id,
737
+ "content": result,
738
+ }
739
+ )
740
+ else:
741
+ # No tool calls — try text-based extraction as fallback
742
+ reply_text = message.content or ""
743
+ fallback_calls = _extract_tool_calls_from_text(reply_text)
744
+ if fallback_calls:
745
+ # Found tool calls in text — execute them
746
+ for fc in fallback_calls:
747
+ fn_name = fc["function"]["name"]
748
+ args = _parse_tool_args(fc["function"].get("arguments", ""))
749
+ detail = _describe_tool(fn_name, args)
750
+ emit_event("tool_start", tool=fn_name, detail=detail)
751
+ result = _execute_tool(fn_name, args, workspace_root)
752
+ if fn_name == "run_command" and isinstance(result, tuple):
753
+ result, cmd_exit_code, cmd_stderr_present = result
754
+ fc["exit_code"] = cmd_exit_code
755
+ fc["stderr_present"] = cmd_stderr_present
756
+ all_tool_calls.append(fc)
757
+ emit_event(
758
+ "tool_done",
759
+ tool=fn_name,
760
+ detail=detail,
761
+ ok=not str(result).startswith("Error"),
762
+ )
763
+ api_messages.append({"role": "assistant", "content": f"{fn_name}({args})"})
764
+ api_messages.append({"role": "tool", "tool_call_id": f"text_{fn_name}", "content": result})
765
+ # Continue loop — the next LLM call will see the results
766
+ continue
767
+ # No tool calls found — return as final reply
768
+ return {
769
+ "messages": messages + [AIMessage(content=reply_text)],
770
+ "tool_calls_log": all_tool_calls,
771
+ }
772
+
773
+ # If we exhausted the tool rounds, return whatever we have.
774
+ last_msg = api_messages[-1]
775
+ reply_text = last_msg.get("content", "(exceeded tool-use rounds)")
776
+ return {
777
+ "messages": messages + [AIMessage(content=reply_text)],
778
+ "tool_calls_log": all_tool_calls,
779
+ }