qai-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.
qai/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """qai - a tiny terminal coding agent running Qwen2.5-Coder 1.5B locally."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["__version__"]
qai/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from qai.cli import run
4
+
5
+ if __name__ == "__main__":
6
+ run()
qai/agent.py ADDED
@@ -0,0 +1,162 @@
1
+ """Agent loop: prompt -> tool calls -> results -> final answer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from qai.backend import LlamaBackend, Message, Response, ToolCall
10
+ from qai.context_manager import ContextManager, ContextPolicy
11
+ from qai.tools.base import ToolRegistry
12
+
13
+ StreamHandler = Callable[[str], None]
14
+
15
+ GUIDANCE = (
16
+ "To use a tool, reply with a single fenced block and nothing else:\n"
17
+ '```json\n{"tool": "read_file", "arguments": {"path": "src/app.py"}}\n```\n'
18
+ "Call one tool at a time and wait for its result. When the task is done, "
19
+ "reply with plain text and no code fence."
20
+ )
21
+
22
+ SUMMARIZE_PROMPT = (
23
+ "Summarize the following transcript of a coding session for your own future "
24
+ "reference. Keep file paths, function names, decisions, edits made, and open "
25
+ "questions. Drop file dumps and tool noise. Answer with under 300 words of "
26
+ "plain prose or bullets, no preamble.\n\n"
27
+ )
28
+
29
+
30
+ @dataclass
31
+ class Agent:
32
+ """Drives the model/tool loop until the model stops calling tools."""
33
+
34
+ backend: LlamaBackend
35
+ registry: ToolRegistry
36
+ max_iterations: int = 12
37
+ system_prompt: str = ""
38
+ on_tool_start: Callable[[ToolCall], None] | None = None
39
+ on_tool_result: Callable[[ToolCall, str, bool], None] | None = None
40
+ on_compact: Callable[[str], None] | None = None
41
+ context: ContextManager = field(
42
+ default_factory=lambda: ContextManager(ContextPolicy())
43
+ )
44
+ last_history: list[Message] = field(default_factory=list)
45
+ """The post-compaction transcript from the most recent :meth:`run`.
46
+
47
+ The REPL adopts this so that trimming and summarization survive between
48
+ turns instead of being thrown away at the end of every one.
49
+ """
50
+
51
+ def run(
52
+ self,
53
+ messages: list[Message],
54
+ stream: StreamHandler | None = None,
55
+ tools_enabled: bool = True,
56
+ ) -> Response:
57
+ system = self.system_prompt
58
+ if tools_enabled:
59
+ system = f"{system}\n\n{GUIDANCE}"
60
+ history: list[Message] = [Message(role="system", content=system), *messages]
61
+ schema = self.registry.schema() if tools_enabled else None
62
+
63
+ try:
64
+ for _ in range(self.max_iterations):
65
+ history = self._budget(history, keep_last=1)
66
+ response = self._complete(history, schema, stream)
67
+ if not response.tool_calls:
68
+ return response
69
+
70
+ history.append(
71
+ Message(
72
+ role="assistant",
73
+ content=response.text,
74
+ tool_calls=[
75
+ {
76
+ "id": call.id,
77
+ "type": "function",
78
+ "function": {"name": call.name, "arguments": call.arguments},
79
+ }
80
+ for call in response.tool_calls
81
+ ],
82
+ )
83
+ )
84
+
85
+ for call in response.tool_calls:
86
+ if self.on_tool_start:
87
+ self.on_tool_start(call)
88
+ result = self.registry.dispatch(call)
89
+ if self.on_tool_result:
90
+ self.on_tool_result(call, result.content, result.ok)
91
+ history.append(
92
+ Message(
93
+ role="tool",
94
+ name=call.name,
95
+ tool_call_id=call.id,
96
+ content=result.to_message(),
97
+ )
98
+ )
99
+
100
+ return Response(
101
+ text=(
102
+ "Stopped after reaching the step limit "
103
+ f"({self.max_iterations}). Re-run with --max-iterations to continue."
104
+ ),
105
+ finish_reason="length",
106
+ )
107
+ finally:
108
+ self.last_history = history
109
+
110
+ def _budget(self, history: list[Message], keep_last: int = 1) -> list[Message]:
111
+ """Auto-compact, then hard-fit whatever is still over budget."""
112
+ before = self.context.compaction_count
113
+ history = self.context.maybe_compact(history, summarizer=self._summarize)
114
+ if self.on_compact and self.context.compaction_count > before:
115
+ self.on_compact(self.context.last_report.summary)
116
+ return self.context.fit(history, keep_last=keep_last)
117
+
118
+ def _summarize(self, transcript: str) -> str:
119
+ """One-shot, low-temperature summary request used by auto-compact."""
120
+ request = [
121
+ Message(role="system", content=SUMMARIZE_PROMPT),
122
+ Message(role="user", content=transcript),
123
+ ]
124
+ try:
125
+ return self.backend.complete(
126
+ request,
127
+ None,
128
+ max_tokens_override=512,
129
+ ).text
130
+ except Exception: # noqa: BLE001 - fall back to extractive summary
131
+ return ""
132
+
133
+ def _complete(
134
+ self,
135
+ history: list[Message],
136
+ schema: list[dict[str, Any]] | None,
137
+ stream: StreamHandler | None,
138
+ ) -> Response:
139
+ # Tool calls must be read from a non-streamed completion: llama.cpp
140
+ # delivers them in the final chunk, and a 1.5B model often emits a
141
+ # fenced JSON block mid-stream that only normalises correctly once the
142
+ # whole turn is available. Text is still emitted incrementally below.
143
+ response = self.backend.complete(history, schema)
144
+ if stream is not None and response.text:
145
+ for piece in _chunks(response.text):
146
+ stream(piece)
147
+ return response
148
+
149
+
150
+ def _chunks(text: str, size: int = 48) -> list[str]:
151
+ return [text[i : i + size] for i in range(0, len(text), size)]
152
+
153
+
154
+ def policy_from_settings(settings: Any) -> ContextPolicy:
155
+ """Build a :class:`ContextPolicy` from CLI/user settings."""
156
+ return ContextPolicy(
157
+ n_ctx=settings.n_ctx,
158
+ reserve_output=max(1_024, settings.max_tokens),
159
+ compact_threshold=settings.compact_threshold,
160
+ max_tool_result_chars=settings.max_tool_result_chars,
161
+ keep_recent_blocks=settings.keep_recent_blocks,
162
+ )
qai/backend.py ADDED
@@ -0,0 +1,239 @@
1
+ """Local model backend: GGUF download plus a llama.cpp chat/tool-call loop.
2
+
3
+ The backend is lazy-imported so that the rest of the CLI (config, workspace,
4
+ tool tests) works on machines without a llama.cpp build.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from collections.abc import Iterator
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from qai.config import Settings, model_cache_dir
17
+
18
+ _LLAMA_IMPORT_ERROR = (
19
+ "llama-cpp-python is required to run the local model.\n"
20
+ "Install it with:\n"
21
+ " pip install 'qai[local]'\n"
22
+ "If you have no C++ toolchain, grab a prebuilt wheel from:\n"
23
+ " https://abetlen.github.io/llama-cpp-python/whl/cpu"
24
+ )
25
+
26
+
27
+ class ModelUnavailableError(RuntimeError):
28
+ """The backend could not be loaded (missing build, missing weights, ...)."""
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class Message:
33
+ role: str
34
+ content: str = ""
35
+ tool_calls: list[dict[str, Any]] | None = None
36
+ name: str | None = None
37
+ tool_call_id: str | None = None
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ payload: dict[str, Any] = {"role": self.role, "content": self.content}
41
+ if self.tool_calls:
42
+ payload["tool_calls"] = self.tool_calls
43
+ if self.name:
44
+ payload["name"] = self.name
45
+ if self.tool_call_id:
46
+ payload["tool_call_id"] = self.tool_call_id
47
+ return payload
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class ToolCall:
52
+ name: str
53
+ arguments: dict[str, Any]
54
+ id: str = "call_0"
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class Response:
59
+ text: str = ""
60
+ tool_calls: list[ToolCall] = field(default_factory=list)
61
+ finish_reason: str = "stop"
62
+
63
+
64
+ def ensure_model_file(settings: Settings, progress: bool = True) -> Path:
65
+ """Return a local path to the GGUF weights, downloading them if needed."""
66
+ target = model_cache_dir() / settings.model_file
67
+ if target.is_file():
68
+ return target
69
+
70
+ try:
71
+ from huggingface_hub import hf_hub_download
72
+ except ImportError as exc: # pragma: no cover - dependency is declared
73
+ raise ModelUnavailableError(
74
+ "huggingface-hub is required to download model weights: pip install huggingface-hub"
75
+ ) from exc
76
+
77
+ target.parent.mkdir(parents=True, exist_ok=True)
78
+ try:
79
+ cached = hf_hub_download(
80
+ repo_id=settings.model_repo,
81
+ filename=settings.model_file,
82
+ cache_dir=str(model_cache_dir() / ".hf"),
83
+ )
84
+ except Exception as exc: # network/auth/offline
85
+ raise ModelUnavailableError(
86
+ f"could not download {settings.model_repo}/{settings.model_file}: {exc}"
87
+ ) from exc
88
+
89
+ downloaded = Path(cached)
90
+ if downloaded.resolve() != target.resolve():
91
+ downloaded.replace(target)
92
+ return target
93
+
94
+
95
+ class LlamaBackend:
96
+ """Thin wrapper over ``llama_cpp.Llama`` exposing chat + tool calls."""
97
+
98
+ def __init__(self, settings: Settings) -> None:
99
+ try:
100
+ from llama_cpp import Llama
101
+ except ImportError as exc: # pragma: no cover - optional dependency
102
+ raise ModelUnavailableError(_LLAMA_IMPORT_ERROR) from exc
103
+
104
+ self.settings = settings
105
+ try:
106
+ weights = ensure_model_file(settings)
107
+ except ModelUnavailableError:
108
+ raise
109
+
110
+ kwargs: dict[str, Any] = {
111
+ "model_path": str(weights),
112
+ "n_ctx": settings.n_ctx,
113
+ "n_gpu_layers": settings.n_gpu_layers,
114
+ "chat_format": "chatml",
115
+ "verbose": False,
116
+ }
117
+ if settings.n_threads:
118
+ kwargs["n_threads"] = settings.n_threads
119
+ try:
120
+ self._llm = Llama(**kwargs)
121
+ except Exception as exc: # pragma: no cover - depends on local build
122
+ raise ModelUnavailableError(f"failed to load model: {exc}") from exc
123
+
124
+ # -- prompting -------------------------------------------------------
125
+ def stream(
126
+ self, messages: list[Message], tools: list[dict[str, Any]] | None = None
127
+ ) -> Iterator[str]:
128
+ """Yield assistant text as it is produced."""
129
+ if not self.settings.stream:
130
+ yield self.complete(messages, tools).text
131
+ return
132
+ try:
133
+ for chunk in self._llm.create_chat_completion(
134
+ messages=[m.to_dict() for m in messages],
135
+ tools=tools or None,
136
+ temperature=self.settings.temperature,
137
+ top_p=self.settings.top_p,
138
+ max_tokens=self.settings.max_tokens,
139
+ stream=True,
140
+ ):
141
+ delta = chunk["choices"][0].get("delta", {})
142
+ piece = delta.get("content")
143
+ if piece:
144
+ yield piece
145
+ except Exception as exc: # pragma: no cover - runtime inference failure
146
+ raise ModelUnavailableError(f"inference failed: {exc}") from exc
147
+
148
+ def complete(
149
+ self,
150
+ messages: list[Message],
151
+ tools: list[dict[str, Any]] | None = None,
152
+ max_tokens_override: int | None = None,
153
+ ) -> Response:
154
+ """Run a single non-streaming completion and normalise tool calls."""
155
+ try:
156
+ raw = self._llm.create_chat_completion(
157
+ messages=[m.to_dict() for m in messages],
158
+ tools=tools or None,
159
+ temperature=self.settings.temperature,
160
+ top_p=self.settings.top_p,
161
+ max_tokens=max_tokens_override or self.settings.max_tokens,
162
+ stream=False,
163
+ )
164
+ except Exception as exc: # pragma: no cover - runtime inference failure
165
+ raise ModelUnavailableError(f"inference failed: {exc}") from exc
166
+
167
+ return parse_response(raw)
168
+
169
+
170
+ _FENCE_RE = re.compile(r"```(?:json|tool_call)?\s*(\{.*?\}|\[.*?\])\s*```", re.DOTALL)
171
+
172
+
173
+ def parse_response(raw: dict[str, Any]) -> Response:
174
+ """Normalise a llama.cpp/OpenAI-shaped completion into a :class:`Response`.
175
+
176
+ Small models frequently emit tool calls as a JSON code fence instead of a
177
+ structured ``tool_calls`` field, so both shapes are accepted.
178
+ """
179
+ choice = (raw.get("choices") or [{}])[0]
180
+ message = choice.get("message") or {}
181
+ text = (message.get("content") or "").strip()
182
+ finish_reason = choice.get("finish_reason") or "stop"
183
+
184
+ calls: list[ToolCall] = []
185
+ for index, raw_call in enumerate(message.get("tool_calls") or []):
186
+ function = raw_call.get("function") or {}
187
+ name = function.get("name") or ""
188
+ args = _as_dict(function.get("arguments"))
189
+ if name:
190
+ calls.append(
191
+ ToolCall(
192
+ name=name,
193
+ arguments=args,
194
+ id=raw_call.get("id") or f"call_{index}",
195
+ )
196
+ )
197
+
198
+ if not calls:
199
+ calls = _parse_fenced_calls(text)
200
+ if calls:
201
+ finish_reason = "tool_calls"
202
+
203
+ return Response(text=text, tool_calls=calls, finish_reason=finish_reason)
204
+
205
+
206
+ def _as_dict(arguments: Any) -> dict[str, Any]:
207
+ if isinstance(arguments, dict):
208
+ return arguments
209
+ if not arguments:
210
+ return {}
211
+ try:
212
+ parsed = json.loads(arguments)
213
+ except (TypeError, json.JSONDecodeError):
214
+ return {}
215
+ return parsed if isinstance(parsed, dict) else {}
216
+
217
+
218
+ def _parse_fenced_calls(text: str) -> list[ToolCall]:
219
+ calls: list[ToolCall] = []
220
+ for index, match in enumerate(_FENCE_RE.finditer(text)):
221
+ try:
222
+ payload = json.loads(match.group(1))
223
+ except json.JSONDecodeError:
224
+ continue
225
+ for item in payload if isinstance(payload, list) else [payload]:
226
+ if not isinstance(item, dict):
227
+ continue
228
+ name = item.get("tool") or item.get("name")
229
+ if not name:
230
+ continue
231
+ args = item.get("arguments") or item.get("args") or {}
232
+ calls.append(
233
+ ToolCall(
234
+ name=str(name),
235
+ arguments=_as_dict(args),
236
+ id=str(item.get("id") or f"call_{index}"),
237
+ )
238
+ )
239
+ return calls