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.
- backend/__init__.py +1 -0
- backend/agents/__init__.py +6 -0
- backend/agents/assessment_agent.py +273 -0
- backend/agents/coding_agent.py +779 -0
- backend/agents/concept_categories.py +131 -0
- backend/agents/concept_detector.py +1217 -0
- backend/agents/debug_agent.py +166 -0
- backend/agents/teacher_agent.py +179 -0
- backend/cli/__init__.py +1 -0
- backend/cli/config_cmd.py +135 -0
- backend/cli/main.py +606 -0
- backend/daemon/__init__.py +1 -0
- backend/daemon/launcher.py +243 -0
- backend/daemon/server.py +453 -0
- backend/daemon/state.py +110 -0
- backend/daemon/static/assets/Gambarino-Regular-BjbcsURA.otf +0 -0
- backend/daemon/static/assets/abnfDiagram-VCTEODGH-CCJBE2aE.js +1 -0
- backend/daemon/static/assets/arc-BEvzHx4o.js +1 -0
- backend/daemon/static/assets/architecture-7GRP2DOG-DaWrPggL.js +1 -0
- backend/daemon/static/assets/architectureDiagram-5GKGNRK7-pR-klcZv.js +36 -0
- backend/daemon/static/assets/array-BifhSqXX.js +1 -0
- backend/daemon/static/assets/blockDiagram-I7D4REHJ-C504Gj6_.js +129 -0
- backend/daemon/static/assets/c4Diagram-7LVT6UL2-BjM04Mni.js +38 -0
- backend/daemon/static/assets/channel-DzSauwD3.js +1 -0
- backend/daemon/static/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- backend/daemon/static/assets/chunk-4HAMMTFA-EgoP78tp.js +62 -0
- backend/daemon/static/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- backend/daemon/static/assets/chunk-75Z2AOVW-EXNbuzun.js +2 -0
- backend/daemon/static/assets/chunk-DU6HZSFF-CF3OK3MZ.js +127 -0
- backend/daemon/static/assets/chunk-F27PBJKO-G71ylWJa.js +1 -0
- backend/daemon/static/assets/chunk-FOHPRMQF-DHwB1DNv.js +161 -0
- backend/daemon/static/assets/chunk-GMAD6QVW-2yfGg28o.js +72 -0
- backend/daemon/static/assets/chunk-GVQU2GXP-C_VeaX4U.js +1 -0
- backend/daemon/static/assets/chunk-IMKFNOWR-CNexRjjn.js +231 -0
- backend/daemon/static/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- backend/daemon/static/assets/chunk-P2QGCYS3-E4AByfsD.js +1 -0
- backend/daemon/static/assets/chunk-POPQ4Y6H-Bisbc2-3.js +1 -0
- backend/daemon/static/assets/chunk-PWAF6VOD-DaoPxZAa.js +1 -0
- backend/daemon/static/assets/chunk-SHT3W25Y-DarPToto.js +168 -0
- backend/daemon/static/assets/chunk-SVP7TREG-DvMOAiwI.js +88 -0
- backend/daemon/static/assets/chunk-TICWLB2K-DheuvyGM.js +206 -0
- backend/daemon/static/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
- backend/daemon/static/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- backend/daemon/static/assets/classDiagram-ZZMXUADV-Ys5zkCXW.js +1 -0
- backend/daemon/static/assets/classDiagram-v2-VYDZK3BY-Ys5zkCXW.js +1 -0
- backend/daemon/static/assets/cose-bilkent-JH36ORCC-DLPLnxrP.js +1 -0
- backend/daemon/static/assets/cynefin-OW5HDTMX-Dv1OY_0y.js +1 -0
- backend/daemon/static/assets/cynefinDiagram-5FMLGOSQ-Ur7MTCmF.js +62 -0
- backend/daemon/static/assets/cytoscape.esm-CECbKnxF.js +321 -0
- backend/daemon/static/assets/dagre-CJLTJMFW.js +1 -0
- backend/daemon/static/assets/dagre-GXQ25YYZ-R3BwTvng.js +4 -0
- backend/daemon/static/assets/defaultLocale-BFoDCU3G.js +1 -0
- backend/daemon/static/assets/diagram-S7CK7UJ4-BxIoEKb4.js +30 -0
- backend/daemon/static/assets/diagram-UQ7AKVKN-DO4cuWN-.js +41 -0
- backend/daemon/static/assets/diagram-VSXAHHWV-DW5imp5t.js +3 -0
- backend/daemon/static/assets/diagram-VX7I27RA-CdZ3k7wQ.js +24 -0
- backend/daemon/static/assets/diagram-Z3DM3KII-DPyjbneL.js +24 -0
- backend/daemon/static/assets/dist-DTg6UBE_.js +1 -0
- backend/daemon/static/assets/ebnfDiagram-PWID7BFC-BO7VQsye.js +1 -0
- backend/daemon/static/assets/erDiagram-RLTQ6QDP-CevvjECq.js +99 -0
- backend/daemon/static/assets/eventmodeling-NTZA5JFV-yNfKR6-v.js +1 -0
- backend/daemon/static/assets/flowDiagram-HODETNUW-B4GT41mU.js +1 -0
- backend/daemon/static/assets/ganttDiagram-EL5Y4UJY-DNW5fWw1.js +292 -0
- backend/daemon/static/assets/gitGraph-4MIJSDKK-DKgVkWaZ.js +1 -0
- backend/daemon/static/assets/gitGraphDiagram-WWUBYQGX-0S7OF9Aj.js +106 -0
- backend/daemon/static/assets/index-D3vj8REa.js +63 -0
- backend/daemon/static/assets/index-D4lMFaiv.css +1 -0
- backend/daemon/static/assets/info-A6RAGUB7-Bxy-SzRN.js +1 -0
- backend/daemon/static/assets/infoDiagram-27XIBGKW-ClzQji6X.js +2 -0
- backend/daemon/static/assets/init-C-OQMol4.js +1 -0
- backend/daemon/static/assets/ishikawaDiagram-5VMMS53U-B3Lo-sS3.js +70 -0
- backend/daemon/static/assets/journeyDiagram-3NMN7TZE-0KL6R2Rz.js +139 -0
- backend/daemon/static/assets/kanban-definition-UXKFOSKX-zt5NbEep.js +89 -0
- backend/daemon/static/assets/katex-CXMH3UgJ.js +257 -0
- backend/daemon/static/assets/line-CiAFRJVJ.js +1 -0
- backend/daemon/static/assets/linear-BI6yqEPV.js +1 -0
- backend/daemon/static/assets/logo_darkmode-BPDdj6GZ.png +0 -0
- backend/daemon/static/assets/logo_lightmode-C3ZWMgAH.png +0 -0
- backend/daemon/static/assets/mermaid-parser.core-DEadI1Ja.js +7 -0
- backend/daemon/static/assets/mindmap-definition-YA3MSWOX-TGKGYg5n.js +96 -0
- backend/daemon/static/assets/ordinal-BDEzSJ7C.js +1 -0
- backend/daemon/static/assets/packet-AYTQ26CC-CZTSuh5x.js +1 -0
- backend/daemon/static/assets/path-fybaL0A-.js +1 -0
- backend/daemon/static/assets/pegDiagram-XKGWAZYB-DGd8LACA.js +1 -0
- backend/daemon/static/assets/pie-WAS4IAKB-B59sPr3Z.js +1 -0
- backend/daemon/static/assets/pieDiagram-E7YTZNPT-CpwxCR3L.js +39 -0
- backend/daemon/static/assets/quadrantDiagram-AXDQQJYC-BwSeF_E_.js +7 -0
- backend/daemon/static/assets/radar-RG4KPBEZ-DAa4JvTb.js +1 -0
- backend/daemon/static/assets/railroad-74A4TZTK-BitdNgDt.js +1 -0
- backend/daemon/static/assets/railroad-abnf-HS5TGJTU-DCrNKqAH.js +1 -0
- backend/daemon/static/assets/railroad-ebnf-LZEXJU2U-DmEwx8OK.js +1 -0
- backend/daemon/static/assets/railroad-peg-WCYAUIDC-CPc8dTCP.js +1 -0
- backend/daemon/static/assets/railroadDiagram-O6MQD6OU-DuizuzwD.js +1 -0
- backend/daemon/static/assets/requirementDiagram-BXWQKSXE-BjMk0yS8.js +84 -0
- backend/daemon/static/assets/rough.esm-Dy-Kn_BL.js +1 -0
- backend/daemon/static/assets/sankeyDiagram-P5KCCOFB-0T_bhkmz.js +40 -0
- backend/daemon/static/assets/sequenceDiagram-WJ2MYXX4-Cwa-1Stp.js +162 -0
- backend/daemon/static/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- backend/daemon/static/assets/src-BH-TyZbA.js +1 -0
- backend/daemon/static/assets/stateDiagram-D77RDMKH-BpQSg_QL.js +1 -0
- backend/daemon/static/assets/stateDiagram-v2-MP3YSRHH-BItVXKof.js +1 -0
- backend/daemon/static/assets/swimlanes-42K2YHIH-h_ED18Vy.js +1 -0
- backend/daemon/static/assets/swimlanesDiagram-VR7AAH4N-D0fo0LN-.js +8 -0
- backend/daemon/static/assets/timeline-definition-24CTP7MA-DKfSO33a.js +120 -0
- backend/daemon/static/assets/treeView-Q6P3EWNA-DAj9fxfC.js +1 -0
- backend/daemon/static/assets/treemap-WGGIJYW6-5IIXD9Zu.js +1 -0
- backend/daemon/static/assets/vennDiagram-4TSXK5OY-BoBvVEci.js +34 -0
- backend/daemon/static/assets/wardley-WFR3VGLG-CGsd7s_-.js +1 -0
- backend/daemon/static/assets/wardleyDiagram-VM6X3IG4-QHdK5NsY.js +78 -0
- backend/daemon/static/assets/xychartDiagram-S5SC5T6Z-MN_fdKCJ.js +7 -0
- backend/daemon/static/index.html +49 -0
- backend/database/__init__.py +1 -0
- backend/database/concept_slug.py +39 -0
- backend/database/concepts.py +804 -0
- backend/llm/__init__.py +5 -0
- backend/llm/client.py +333 -0
- backend/llm/config.py +254 -0
- backend/llm/key_setup.py +237 -0
- backend/main.py +13 -0
- backend/orchestrator/__init__.py +1 -0
- backend/orchestrator/events.py +52 -0
- backend/orchestrator/graph.py +316 -0
- backend/orchestrator/modes.py +125 -0
- codelith-0.1.0.dist-info/METADATA +301 -0
- codelith-0.1.0.dist-info/RECORD +129 -0
- codelith-0.1.0.dist-info/WHEEL +5 -0
- codelith-0.1.0.dist-info/entry_points.txt +2 -0
- codelith-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
}
|