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 +6 -0
- qai/__main__.py +6 -0
- qai/agent.py +162 -0
- qai/backend.py +239 -0
- qai/cli.py +365 -0
- qai/config.py +160 -0
- qai/context.py +139 -0
- qai/context_manager.py +368 -0
- qai/py.typed +0 -0
- qai/tools/__init__.py +8 -0
- qai/tools/base.py +119 -0
- qai/tools/fs.py +308 -0
- qai/ui.py +157 -0
- qai/workspace.py +118 -0
- qai_cli-0.1.0.dist-info/METADATA +304 -0
- qai_cli-0.1.0.dist-info/RECORD +19 -0
- qai_cli-0.1.0.dist-info/WHEEL +4 -0
- qai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- qai_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
qai/__init__.py
ADDED
qai/__main__.py
ADDED
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
|