feat read files

This commit is contained in:
fsylvain committed 2026-09-22 16:35:13 +02:00
1 parent 78152d9974
commit f129aef5a5
3 files changed
+175 -11

No files matched your search

+68 -4
View File
@@ -49,7 +49,9 @@ SYSTEM_PROMPT = (
"identifiers, filenames, and commands mentioned in prose. Use the "
"web_search tool when a question depends on current information you "
"may not know (recent releases, changelogs, current best practices, "
"error messages you don't recognize)."
"error messages you don't recognize). If the user provides a local "
"file path or asks to inspect a specific file, prefer the read_file "
"tool instead of web_search."
)
# Number of prior user/assistant exchanges to keep in the prompt. Bounds the
@@ -75,6 +77,29 @@ WEB_SEARCH_TOOL = {
},
}
FILE_READ_TOOL = {
"type": "function",
"function": {
"name": "read_file",
"description": "Read a local file from disk and return its contents.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Absolute or relative path to the file."},
"start_line": {
"type": "integer",
"description": "Optional 1-based line number to start reading from.",
},
"end_line": {
"type": "integer",
"description": "Optional 1-based line number to stop reading at.",
},
},
"required": ["path"],
},
},
}
# Matches the <tool_call><function=name>...</function></tool_call> blocks
# Qwen3.5's chat template trains it to emit when it wants to invoke a tool -
# an XML-style syntax, not Qwen2.5's JSON-in-<tool_call> format.
@@ -109,7 +134,35 @@ def web_search(query: str, max_results: int = 5) -> str:
return "\n\n".join(f"{r['title']}\n{r['href']}\n{r['body']}" for r in results)
TOOLS = {"web_search": web_search}
def read_file(path: str, start_line: int | None = None, end_line: int | None = None) -> str:
"""Read a local file from disk. If start_line/end_line are provided, read
only that line range (1-based, inclusive). Returns a helpful error string if
the file cannot be read."""
file_path = Path(path)
if not file_path.exists():
return f"File not found: {path}"
if not file_path.is_file():
return f"Not a file: {path}"
try:
lines = file_path.read_text(encoding="utf-8").splitlines()
except UnicodeDecodeError:
try:
lines = file_path.read_text(encoding="utf-8-sig").splitlines()
except Exception as exc: # pragma: no cover - defensive fallback
return f"Could not decode file: {path} ({exc})"
if start_line is None and end_line is None:
return "\n".join(lines) + ("\n" if lines and file_path.read_bytes() else "")
start = 1 if start_line is None else max(1, int(start_line))
end = len(lines) if end_line is None else min(len(lines), int(end_line))
if start > end:
return ""
return "\n".join(lines[start - 1 : end]) + ("\n" if start <= end and end <= len(lines) else "")
TOOLS = {"web_search": web_search, "read_file": read_file}
def extract_tool_calls(text: str) -> list[dict]:
@@ -140,6 +193,9 @@ class ChatSession:
created_at: str
updated_at: str
history: list[tuple[str, str]] = field(default_factory=list)
# Tokens occupied in this chat's own context after its last model call.
# None until that chat has been evaluated in some session of the app.
context_tokens: int | None = None
def new_session() -> ChatSession:
@@ -167,6 +223,7 @@ def save_session(session: ChatSession) -> None:
"created_at": session.created_at,
"updated_at": session.updated_at,
"history": [[question, answer] for question, answer in session.history],
"context_tokens": session.context_tokens,
}
_session_path(session.id).write_text(json.dumps(payload, indent=2), encoding="utf-8")
@@ -191,6 +248,7 @@ def list_sessions() -> list[ChatSession]:
created_at=data["created_at"],
updated_at=data["updated_at"],
history=[tuple(turn) for turn in data["history"]],
context_tokens=data.get("context_tokens"),
)
)
except (json.JSONDecodeError, OSError, KeyError, TypeError):
@@ -367,7 +425,13 @@ def ask(
invoked the same way but for text inside the model's <think> block,
letting a caller render its reasoning trace separately from the answer."""
for _ in range(MAX_TOOL_ROUNDS):
content = _stream_content(llm, messages, [WEB_SEARCH_TOOL], on_token, on_thinking_token)
content = _stream_content(
llm,
messages,
[WEB_SEARCH_TOOL, FILE_READ_TOOL],
on_token,
on_thinking_token,
)
if on_usage is not None:
on_usage({"total_tokens": llm.n_tokens})
calls = extract_tool_calls(content)
@@ -385,7 +449,7 @@ def ask(
result = f"Unknown tool: {name}"
else:
if on_tool_call is not None:
on_tool_call(args.get("query", ""))
on_tool_call(name, args.get("query") or args.get("path", ""))
result = func(**args)
messages.append({"role": "tool", "content": result})