symbio-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.
- symbio/__init__.py +103 -0
- symbio/agent.py +439 -0
- symbio/ansi_scanner.py +157 -0
- symbio/app/__init__.py +21 -0
- symbio/app/chat.py +5706 -0
- symbio/app/cli.py +1056 -0
- symbio/app/config.py +648 -0
- symbio/app/cron.py +279 -0
- symbio/app/dispatch.py +1160 -0
- symbio/app/eval.py +409 -0
- symbio/app/golden.py +689 -0
- symbio/app/health.py +647 -0
- symbio/app/learn.py +890 -0
- symbio/app/local_telemetry.py +274 -0
- symbio/app/mcp_bridge.py +67 -0
- symbio/app/mcp_tools.py +309 -0
- symbio/app/memory.py +253 -0
- symbio/app/mlx_compat.py +249 -0
- symbio/app/pending.py +336 -0
- symbio/app/prompts.py +185 -0
- symbio/app/prune.py +279 -0
- symbio/app/retrain.py +104 -0
- symbio/app/sandbox.py +478 -0
- symbio/app/security.py +505 -0
- symbio/app/sessions.py +56 -0
- symbio/app/setup.py +306 -0
- symbio/app/skill_eval.py +853 -0
- symbio/app/skills.py +1330 -0
- symbio/app/telegram.py +809 -0
- symbio/app/telemetry.py +233 -0
- symbio/app/tool_eval.py +468 -0
- symbio/app/tooling.py +1826 -0
- symbio/app/training.py +2601 -0
- symbio/app/web.py +197 -0
- symbio/app/wildcards.py +391 -0
- symbio/chat.py +433 -0
- symbio/computer.py +518 -0
- symbio/config.py +323 -0
- symbio/constants.py +252 -0
- symbio/learn.py +304 -0
- symbio/llm.py +574 -0
- symbio/mcp/__init__.py +21 -0
- symbio/mcp/benchmark.py +241 -0
- symbio/mcp/benchmark_mlx.py +338 -0
- symbio/mcp/config.py +47 -0
- symbio/mcp/frontier_client.py +27 -0
- symbio/mcp/learn.py +177 -0
- symbio/mcp/memory.py +122 -0
- symbio/mcp/models.py +53 -0
- symbio/mcp/ollama_client.py +116 -0
- symbio/mcp/server.py +124 -0
- symbio/rag.py +587 -0
- symbio/safety.py +950 -0
- symbio/sandbox.py +174 -0
- symbio/store.py +94 -0
- symbio/tools.py +1119 -0
- symbio/utils.py +283 -0
- symbio_cli-0.1.0.dist-info/METADATA +1280 -0
- symbio_cli-0.1.0.dist-info/RECORD +67 -0
- symbio_cli-0.1.0.dist-info/WHEEL +5 -0
- symbio_cli-0.1.0.dist-info/entry_points.txt +4 -0
- symbio_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- symbio_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
- symbio_cli-0.1.0.dist-info/top_level.txt +2 -0
- symbio_desktop/__init__.py +1 -0
- symbio_desktop/cli.py +59 -0
- symbio_desktop/server.py +478 -0
symbio/__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Symbio: a personal, autonomous, self-fine-tuning AI assistant."""
|
|
2
|
+
|
|
3
|
+
from symbio.agent import AIAgent
|
|
4
|
+
from symbio.chat import build_system_prompt, chat_loop
|
|
5
|
+
from symbio.config import (
|
|
6
|
+
detect_model_type,
|
|
7
|
+
list_model_presets,
|
|
8
|
+
load_config,
|
|
9
|
+
maybe_update_names_from_message,
|
|
10
|
+
save_config,
|
|
11
|
+
setup_names,
|
|
12
|
+
switch_model_preset,
|
|
13
|
+
)
|
|
14
|
+
from symbio.constants import (
|
|
15
|
+
ADAPTER_DIR,
|
|
16
|
+
CONFIG_FILE,
|
|
17
|
+
DATA_DIR,
|
|
18
|
+
DEFAULT_CONFIG,
|
|
19
|
+
DIGEST_MANIFEST,
|
|
20
|
+
LOG_DIR,
|
|
21
|
+
MISTAKES_ARCHIVE_DIR,
|
|
22
|
+
MISTAKES_DIR,
|
|
23
|
+
MODELS_FILE,
|
|
24
|
+
NOTES_DIR,
|
|
25
|
+
PROJECT_DIR,
|
|
26
|
+
SANDBOX_DIR,
|
|
27
|
+
SCREENSHOTS_DIR,
|
|
28
|
+
TRAIN_FILE,
|
|
29
|
+
VALID_FILE,
|
|
30
|
+
)
|
|
31
|
+
from symbio.learn import (
|
|
32
|
+
_archive_mistake_notes,
|
|
33
|
+
_digest_mistakes_to_training,
|
|
34
|
+
_find_correction_sample,
|
|
35
|
+
_is_correction,
|
|
36
|
+
_is_system_observation,
|
|
37
|
+
_looks_like_correction,
|
|
38
|
+
_mistake_note_count,
|
|
39
|
+
_safe_mistake_filename,
|
|
40
|
+
_save_mistake_note,
|
|
41
|
+
learn_from_last_correction,
|
|
42
|
+
maybe_train_on_mistakes,
|
|
43
|
+
)
|
|
44
|
+
from symbio.llm import (
|
|
45
|
+
append_chat_pair,
|
|
46
|
+
append_training_text,
|
|
47
|
+
build_chat_training_sample,
|
|
48
|
+
digest_notes_to_training,
|
|
49
|
+
prune_adapters,
|
|
50
|
+
run_training,
|
|
51
|
+
seed_training_data,
|
|
52
|
+
)
|
|
53
|
+
from symbio.sandbox import _is_code_safe, _run_execute_code, _run_sandboxed, _write_symbio_tools_stub
|
|
54
|
+
from symbio.store import SessionStore
|
|
55
|
+
from symbio.utils import (
|
|
56
|
+
parse_tools,
|
|
57
|
+
clean_response,
|
|
58
|
+
ensure_seed_notes,
|
|
59
|
+
save_note,
|
|
60
|
+
strip_generation_artifacts,
|
|
61
|
+
strip_tool_tags,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"AIAgent",
|
|
66
|
+
"ADAPTER_DIR",
|
|
67
|
+
"CONFIG_FILE",
|
|
68
|
+
"DATA_DIR",
|
|
69
|
+
"DEFAULT_CONFIG",
|
|
70
|
+
"DIGEST_MANIFEST",
|
|
71
|
+
"LOG_DIR",
|
|
72
|
+
"MISTAKES_ARCHIVE_DIR",
|
|
73
|
+
"MISTAKES_DIR",
|
|
74
|
+
"MODELS_FILE",
|
|
75
|
+
"NOTES_DIR",
|
|
76
|
+
"PROJECT_DIR",
|
|
77
|
+
"SANDBOX_DIR",
|
|
78
|
+
"SCREENSHOTS_DIR",
|
|
79
|
+
"TRAIN_FILE",
|
|
80
|
+
"VALID_FILE",
|
|
81
|
+
"SessionStore",
|
|
82
|
+
"build_system_prompt",
|
|
83
|
+
"chat_loop",
|
|
84
|
+
"clean_response",
|
|
85
|
+
"detect_model_type",
|
|
86
|
+
"digest_notes_to_training",
|
|
87
|
+
"ensure_seed_notes",
|
|
88
|
+
"learn_from_last_correction",
|
|
89
|
+
"list_model_presets",
|
|
90
|
+
"load_config",
|
|
91
|
+
"maybe_train_on_mistakes",
|
|
92
|
+
"maybe_update_names_from_message",
|
|
93
|
+
"parse_tools",
|
|
94
|
+
"prune_adapters",
|
|
95
|
+
"run_training",
|
|
96
|
+
"save_config",
|
|
97
|
+
"save_note",
|
|
98
|
+
"seed_training_data",
|
|
99
|
+
"setup_names",
|
|
100
|
+
"strip_generation_artifacts",
|
|
101
|
+
"strip_tool_tags",
|
|
102
|
+
"switch_model_preset",
|
|
103
|
+
]
|
symbio/agent.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""AIAgent class for Symbio."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from mlx_lm import load, stream_generate
|
|
15
|
+
from mlx_lm.sample_utils import make_logits_processors, make_sampler
|
|
16
|
+
|
|
17
|
+
from symbio.chat import build_system_prompt
|
|
18
|
+
from symbio.config import can_run_lora, detect_model_type
|
|
19
|
+
from symbio.constants import ADAPTER_DIR, DEFAULT_CONFIG, LOG_DIR, PROJECT_DIR
|
|
20
|
+
from symbio.learn import _is_system_observation
|
|
21
|
+
from symbio.llm import run_training, save_history_pairs
|
|
22
|
+
from symbio.app.training import THINKING_ENABLED
|
|
23
|
+
from symbio.store import SessionStore
|
|
24
|
+
from symbio.tools import (
|
|
25
|
+
build_tool_registry,
|
|
26
|
+
execute_tools,
|
|
27
|
+
openai_tool_schemas,
|
|
28
|
+
run_single_tool,
|
|
29
|
+
tool_few_shots,
|
|
30
|
+
tool_metadata,
|
|
31
|
+
)
|
|
32
|
+
from symbio.utils import (
|
|
33
|
+
clean_response,
|
|
34
|
+
has_dangling_tool_call,
|
|
35
|
+
parse_tools,
|
|
36
|
+
strip_dangling_tool_call,
|
|
37
|
+
strip_generation_artifacts,
|
|
38
|
+
strip_tool_tags,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
from symbio.rag import Retriever
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
from planner import TrainingPlanner
|
|
45
|
+
except ImportError:
|
|
46
|
+
# planner.py lives at the project root; ensure the project root is on
|
|
47
|
+
# sys.path when symbio is installed as a package (e.g. the `symb` console
|
|
48
|
+
# entry point does not add the CWD to sys.path in some environments).
|
|
49
|
+
sys.path.insert(0, str(PROJECT_DIR))
|
|
50
|
+
from planner import TrainingPlanner
|
|
51
|
+
|
|
52
|
+
# Browser / desktop automation helpers (lazy-imported inside runners if missing).
|
|
53
|
+
try:
|
|
54
|
+
from symbio.computer import (
|
|
55
|
+
BrowserSession,
|
|
56
|
+
desktop_click,
|
|
57
|
+
desktop_move,
|
|
58
|
+
desktop_press,
|
|
59
|
+
desktop_screenshot,
|
|
60
|
+
desktop_type,
|
|
61
|
+
)
|
|
62
|
+
except Exception:
|
|
63
|
+
BrowserSession = None # type: ignore
|
|
64
|
+
desktop_click = desktop_move = desktop_press = desktop_screenshot = desktop_type = None # type: ignore
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger("chat")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _Spinner:
|
|
71
|
+
"""Terminal spinner shown while waiting for visible model output.
|
|
72
|
+
|
|
73
|
+
Runs on a daemon thread and anchors itself with carriage returns; stop()
|
|
74
|
+
erases the line so streamed text can take its place. No-op when stdout
|
|
75
|
+
is not a TTY (tests, pipes).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
79
|
+
|
|
80
|
+
def __init__(self, label: str = "thinking…"):
|
|
81
|
+
self.label = label
|
|
82
|
+
self._stop_event = threading.Event()
|
|
83
|
+
self._thread: threading.Thread | None = None
|
|
84
|
+
self.active = sys.stdout.isatty()
|
|
85
|
+
|
|
86
|
+
def start(self):
|
|
87
|
+
if not self.active or self._thread is not None:
|
|
88
|
+
return
|
|
89
|
+
self._stop_event.clear()
|
|
90
|
+
|
|
91
|
+
def _spin():
|
|
92
|
+
i = 0
|
|
93
|
+
while not self._stop_event.wait(0.08):
|
|
94
|
+
frame = self._FRAMES[i % len(self._FRAMES)]
|
|
95
|
+
sys.stdout.write(f"\r{frame} {self.label}")
|
|
96
|
+
sys.stdout.flush()
|
|
97
|
+
i += 1
|
|
98
|
+
|
|
99
|
+
self._thread = threading.Thread(target=_spin, daemon=True)
|
|
100
|
+
self._thread.start()
|
|
101
|
+
|
|
102
|
+
def stop(self):
|
|
103
|
+
if self._thread is None:
|
|
104
|
+
return
|
|
105
|
+
self._stop_event.set()
|
|
106
|
+
self._thread.join()
|
|
107
|
+
self._thread = None
|
|
108
|
+
sys.stdout.write("\r\033[K")
|
|
109
|
+
sys.stdout.flush()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _StreamPrinter:
|
|
113
|
+
"""Incrementally print the visible (tool-markup-free) part of a streaming reply.
|
|
114
|
+
|
|
115
|
+
Tool calls and other tags are suppressed live via strip_tool_tags; the
|
|
116
|
+
name prefix is only shown once the first visible character arrives, so
|
|
117
|
+
tool-only replies print nothing.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, prefix: str, spinner: _Spinner | None = None):
|
|
121
|
+
self.prefix = prefix
|
|
122
|
+
self.spinner = spinner
|
|
123
|
+
self.printed = ""
|
|
124
|
+
self.prefix_shown = False
|
|
125
|
+
|
|
126
|
+
def update(self, full_text: str):
|
|
127
|
+
visible = strip_tool_tags(full_text)
|
|
128
|
+
# Only print monotonic extensions; cleanup can transiently shrink the
|
|
129
|
+
# visible text while a tag is being generated.
|
|
130
|
+
if not visible or not visible.startswith(self.printed):
|
|
131
|
+
return
|
|
132
|
+
delta = visible[len(self.printed):]
|
|
133
|
+
if not delta:
|
|
134
|
+
return
|
|
135
|
+
if not self.prefix_shown:
|
|
136
|
+
if self.spinner is not None:
|
|
137
|
+
self.spinner.stop()
|
|
138
|
+
sys.stdout.write(self.prefix)
|
|
139
|
+
self.prefix_shown = True
|
|
140
|
+
sys.stdout.write(delta)
|
|
141
|
+
sys.stdout.flush()
|
|
142
|
+
self.printed = visible
|
|
143
|
+
|
|
144
|
+
def close(self, final_display: str) -> bool:
|
|
145
|
+
"""Reconcile streamed output with the final cleaned text.
|
|
146
|
+
|
|
147
|
+
Returns True if the reply is now fully printed on screen.
|
|
148
|
+
"""
|
|
149
|
+
if not self.prefix_shown:
|
|
150
|
+
return False
|
|
151
|
+
if final_display.startswith(self.printed):
|
|
152
|
+
sys.stdout.write(final_display[len(self.printed):] + "\n")
|
|
153
|
+
else:
|
|
154
|
+
# A retry or final cleanup diverged from what was streamed;
|
|
155
|
+
# reprint the canonical line so the transcript is correct.
|
|
156
|
+
sys.stdout.write("\n" + self.prefix + final_display + "\n")
|
|
157
|
+
sys.stdout.flush()
|
|
158
|
+
self.printed = final_display
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AIAgent:
|
|
163
|
+
"""Hermes-style autonomous agent loop over an MLX model + LoRA adapter."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
config: dict[str, Any],
|
|
168
|
+
model: Any,
|
|
169
|
+
tokenizer: Any,
|
|
170
|
+
adapter_loaded: bool,
|
|
171
|
+
):
|
|
172
|
+
self.config = config
|
|
173
|
+
self.model = model
|
|
174
|
+
self.tokenizer = tokenizer
|
|
175
|
+
self.adapter_loaded = adapter_loaded
|
|
176
|
+
self.tools = build_tool_registry(self)
|
|
177
|
+
self.system_prompt = build_system_prompt(
|
|
178
|
+
config["assistant_name"], config["user_name"], self.tools
|
|
179
|
+
)
|
|
180
|
+
self.history: list[dict[str, str]] = []
|
|
181
|
+
self.sampler = make_sampler(
|
|
182
|
+
temp=config["agent"]["temperature"],
|
|
183
|
+
top_p=config["agent"]["top_p"],
|
|
184
|
+
)
|
|
185
|
+
# Speculative decoding: a tiny draft model predicts ahead cheaply and
|
|
186
|
+
# the big model verifies several tokens per forward pass. On a
|
|
187
|
+
# bandwidth-bound 2-bit model this is the single biggest speed lever.
|
|
188
|
+
# Only loaded if configured — leave "draft_model" unset to disable.
|
|
189
|
+
self.draft_model = None
|
|
190
|
+
draft_name = config["agent"].get("draft_model")
|
|
191
|
+
if draft_name:
|
|
192
|
+
from mlx_lm import load as _mlx_load
|
|
193
|
+
self.draft_model, _ = _mlx_load(draft_name)
|
|
194
|
+
|
|
195
|
+
# Near-greedy sampling on a small overfit model degenerates into
|
|
196
|
+
# repetition loops on out-of-distribution input; penalize repeats.
|
|
197
|
+
self.logits_processors = make_logits_processors(
|
|
198
|
+
repetition_penalty=config["agent"].get("repetition_penalty", 1.15),
|
|
199
|
+
repetition_context_size=64,
|
|
200
|
+
)
|
|
201
|
+
self.session_id = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
202
|
+
self.session_log = LOG_DIR / f"session_{self.session_id}.jsonl"
|
|
203
|
+
self.store = SessionStore(PROJECT_DIR / "logs" / "sessions.db")
|
|
204
|
+
self.store.new_session(self.session_id)
|
|
205
|
+
self.retriever = Retriever(
|
|
206
|
+
config, session_store=self.store, exclude_session_id=self.session_id
|
|
207
|
+
)
|
|
208
|
+
self.planner = TrainingPlanner(config)
|
|
209
|
+
self._code_calls_this_turn = 0
|
|
210
|
+
self._browser_session = BrowserSession() if BrowserSession else None
|
|
211
|
+
|
|
212
|
+
def _openai_tool_schemas(self) -> list[dict[str, Any]]:
|
|
213
|
+
return openai_tool_schemas(self.tools)
|
|
214
|
+
|
|
215
|
+
def _tool_few_shots(self) -> list[dict[str, str]]:
|
|
216
|
+
return tool_few_shots(self.config)
|
|
217
|
+
|
|
218
|
+
def _tool_metadata(self, name: str) -> dict[str, Any]:
|
|
219
|
+
return tool_metadata(name, self.tools, self)
|
|
220
|
+
|
|
221
|
+
def _execute_tools(self, tools: list[tuple[str, dict[str, Any]]]) -> list[tuple[str, str]]:
|
|
222
|
+
return execute_tools(self, tools)
|
|
223
|
+
|
|
224
|
+
def _run_single_tool(self, name: str, params: dict[str, Any]) -> str:
|
|
225
|
+
return run_single_tool(self, name, params)
|
|
226
|
+
|
|
227
|
+
def _generate_stream(self, prompt: str, printer: _StreamPrinter | None) -> str:
|
|
228
|
+
"""Generate a reply, echoing visible text to stdout as tokens arrive.
|
|
229
|
+
|
|
230
|
+
A spinner covers prompt processing and any stretch of non-visible
|
|
231
|
+
output (e.g. while a tool call is being written); it disappears the
|
|
232
|
+
moment the first visible character streams.
|
|
233
|
+
"""
|
|
234
|
+
spinner = _Spinner()
|
|
235
|
+
if printer is not None:
|
|
236
|
+
printer.spinner = spinner
|
|
237
|
+
spinner.start()
|
|
238
|
+
parts: list[str] = []
|
|
239
|
+
try:
|
|
240
|
+
for response in stream_generate(
|
|
241
|
+
self.model,
|
|
242
|
+
self.tokenizer,
|
|
243
|
+
prompt=prompt,
|
|
244
|
+
sampler=self.sampler,
|
|
245
|
+
logits_processors=self.logits_processors,
|
|
246
|
+
max_tokens=self.config["agent"].get("max_output_len", 1024),
|
|
247
|
+
draft_model=self.draft_model,
|
|
248
|
+
):
|
|
249
|
+
parts.append(response.text)
|
|
250
|
+
# Runaway-loop breaker: degenerate output (repeated chars,
|
|
251
|
+
# cycling patterns) reuses the same 4-grams over and over,
|
|
252
|
+
# while natural text keeps them mostly unique.
|
|
253
|
+
if len(parts) % 16 == 0:
|
|
254
|
+
tail = "".join(parts)[-320:]
|
|
255
|
+
if len(tail) >= 320:
|
|
256
|
+
grams = {tail[i:i + 4] for i in range(len(tail) - 3)}
|
|
257
|
+
if len(grams) / (len(tail) - 3) < 0.35:
|
|
258
|
+
break
|
|
259
|
+
if printer is not None:
|
|
260
|
+
printer.update("".join(parts))
|
|
261
|
+
finally:
|
|
262
|
+
spinner.stop()
|
|
263
|
+
return "".join(parts)
|
|
264
|
+
|
|
265
|
+
def update_identity(self, assistant_name: str, user_name: str):
|
|
266
|
+
self.config["assistant_name"] = assistant_name
|
|
267
|
+
self.config["user_name"] = user_name
|
|
268
|
+
self.system_prompt = build_system_prompt(assistant_name, user_name, self.tools)
|
|
269
|
+
|
|
270
|
+
def _persist_turn(self, role: str, content: str):
|
|
271
|
+
entry = {
|
|
272
|
+
"timestamp": datetime.now().isoformat(),
|
|
273
|
+
"role": role,
|
|
274
|
+
"content": content,
|
|
275
|
+
}
|
|
276
|
+
with open(self.session_log, "a", encoding="utf-8") as f:
|
|
277
|
+
f.write(json.dumps(entry) + "\n")
|
|
278
|
+
self.store.append(self.session_id, role, content)
|
|
279
|
+
|
|
280
|
+
def run(self, user_input: str) -> dict[str, Any]:
|
|
281
|
+
self._code_calls_this_turn = 0
|
|
282
|
+
self.history.append({"role": "user", "content": user_input})
|
|
283
|
+
self._persist_turn("user", user_input)
|
|
284
|
+
|
|
285
|
+
final_text = ""
|
|
286
|
+
max_turns = self.config["agent"].get("max_turns") or self.config["agent"].get("max_tool_rounds", 10)
|
|
287
|
+
history_limit = self.config["agent"]["history_limit"]
|
|
288
|
+
|
|
289
|
+
executed_sigs: set[tuple[str, str]] = set()
|
|
290
|
+
mutating_types_executed: set[str] = set()
|
|
291
|
+
tools_used: list[str] = []
|
|
292
|
+
|
|
293
|
+
for _round in range(max_turns):
|
|
294
|
+
messages = [{"role": "system", "content": self.system_prompt}]
|
|
295
|
+
|
|
296
|
+
# Retrieve relevant notes/sessions on the first round and inject
|
|
297
|
+
# them as additional context before the conversation history.
|
|
298
|
+
if _round == 0:
|
|
299
|
+
context = self.retriever.build_context(user_input)
|
|
300
|
+
if context:
|
|
301
|
+
messages.append({"role": "user", "content": context})
|
|
302
|
+
|
|
303
|
+
if _round == 0:
|
|
304
|
+
messages.extend(self._tool_few_shots())
|
|
305
|
+
|
|
306
|
+
messages.extend(self.history[-history_limit:])
|
|
307
|
+
|
|
308
|
+
native_tools = self.config.get("agent", {}).get("native_tools", False)
|
|
309
|
+
prompt = self.tokenizer.apply_chat_template(
|
|
310
|
+
messages,
|
|
311
|
+
tokenize=False,
|
|
312
|
+
add_generation_prompt=True,
|
|
313
|
+
enable_thinking=THINKING_ENABLED,
|
|
314
|
+
**({"tools": self._openai_tool_schemas()} if native_tools else {}),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
reply = ""
|
|
318
|
+
tools: list[tuple[str, dict[str, Any]]] = []
|
|
319
|
+
mlx_error = False
|
|
320
|
+
printer = _StreamPrinter(f"{self.config['assistant_name']:8}: ")
|
|
321
|
+
for _attempt in range(2):
|
|
322
|
+
try:
|
|
323
|
+
# Stream only the first attempt; a retry reconciles later
|
|
324
|
+
# via printer.close() so text is never shown twice.
|
|
325
|
+
raw_reply = self._generate_stream(
|
|
326
|
+
prompt, printer if _attempt == 0 else None
|
|
327
|
+
)
|
|
328
|
+
except Exception as e:
|
|
329
|
+
print(f"[MLX Error: {e}]")
|
|
330
|
+
mlx_error = True
|
|
331
|
+
break
|
|
332
|
+
reply = strip_generation_artifacts(clean_response(raw_reply.strip()))
|
|
333
|
+
tools = parse_tools(reply)
|
|
334
|
+
# A dangling <tool_call> means generation stopped mid-call;
|
|
335
|
+
# resample once before giving up on the tool call.
|
|
336
|
+
if not has_dangling_tool_call(reply):
|
|
337
|
+
break
|
|
338
|
+
if mlx_error:
|
|
339
|
+
break
|
|
340
|
+
if not tools:
|
|
341
|
+
# Drop truncated tool markup so it never reaches the user,
|
|
342
|
+
# the history, or the session store (RAG would re-inject it).
|
|
343
|
+
reply = strip_dangling_tool_call(reply)
|
|
344
|
+
|
|
345
|
+
unique_tools: list[tuple[str, dict[str, Any]]] = []
|
|
346
|
+
for name, params in tools:
|
|
347
|
+
sig = (name, json.dumps(params, sort_keys=True, ensure_ascii=False))
|
|
348
|
+
if sig in executed_sigs:
|
|
349
|
+
continue
|
|
350
|
+
executed_sigs.add(sig)
|
|
351
|
+
meta = self._tool_metadata(name)
|
|
352
|
+
if not meta.get("readonly") and name in mutating_types_executed:
|
|
353
|
+
continue
|
|
354
|
+
if not meta.get("readonly"):
|
|
355
|
+
mutating_types_executed.add(name)
|
|
356
|
+
unique_tools.append((name, params))
|
|
357
|
+
tools = unique_tools
|
|
358
|
+
|
|
359
|
+
display = strip_tool_tags(reply)
|
|
360
|
+
final_text = display
|
|
361
|
+
|
|
362
|
+
if display.strip():
|
|
363
|
+
if not printer.close(display):
|
|
364
|
+
print(f"{self.config['assistant_name']:8}: {display}")
|
|
365
|
+
logger.info(f"{self.config['assistant_name']}: {display}")
|
|
366
|
+
elif printer.prefix_shown:
|
|
367
|
+
# Streamed text that cleanup later removed; end the line.
|
|
368
|
+
print()
|
|
369
|
+
|
|
370
|
+
self.history.append({"role": "assistant", "content": reply})
|
|
371
|
+
self._persist_turn("assistant", reply)
|
|
372
|
+
|
|
373
|
+
if not tools:
|
|
374
|
+
if not display.strip():
|
|
375
|
+
final_text = "[Received an empty reply.]"
|
|
376
|
+
print(f"{self.config['assistant_name']:8}: {final_text}")
|
|
377
|
+
break
|
|
378
|
+
|
|
379
|
+
tool_results = self._execute_tools(tools)
|
|
380
|
+
tools_used.extend(name for name, _ in tools)
|
|
381
|
+
observations: list[str] = []
|
|
382
|
+
for name, out in tool_results:
|
|
383
|
+
indented = out.replace("\n", "\n ")
|
|
384
|
+
print(f" [Observation {name}] {indented}")
|
|
385
|
+
observations.append(f"{name}: {out}")
|
|
386
|
+
tool_id = f"{name}_{hashlib.md5(out.encode()).hexdigest()[:8]}"
|
|
387
|
+
self.history.append({"role": "tool", "content": out, "tool_call_id": tool_id})
|
|
388
|
+
self._persist_turn("tool", f"[{tool_id}] {out}")
|
|
389
|
+
|
|
390
|
+
obs_text = "\n".join(observations)
|
|
391
|
+
observation_msg = (
|
|
392
|
+
f"[System observation — do NOT repeat the same tool call; reply to the user]: {obs_text}"
|
|
393
|
+
)
|
|
394
|
+
self.history.append({"role": "user", "content": observation_msg})
|
|
395
|
+
self._persist_turn("user", observation_msg)
|
|
396
|
+
else:
|
|
397
|
+
final_text = "[Reached the maximum number of turns.]"
|
|
398
|
+
print(f"{self.config['assistant_name']:8}: {final_text}")
|
|
399
|
+
|
|
400
|
+
while len(self.history) > history_limit + 8:
|
|
401
|
+
self.history.pop(0)
|
|
402
|
+
|
|
403
|
+
self.planner.record_turn(user_input, final_text, tools=list(dict.fromkeys(tools_used)))
|
|
404
|
+
return {"text": final_text, "history": self.history}
|
|
405
|
+
|
|
406
|
+
def chat(self, user_input: str) -> str:
|
|
407
|
+
return self.run(user_input)["text"]
|
|
408
|
+
|
|
409
|
+
def forget_last(self) -> int:
|
|
410
|
+
removed = 0
|
|
411
|
+
while self.history and self.history[-1]["role"] == "assistant":
|
|
412
|
+
self.history.pop()
|
|
413
|
+
removed += 1
|
|
414
|
+
while (
|
|
415
|
+
self.history
|
|
416
|
+
and self.history[-1]["role"] == "user"
|
|
417
|
+
and not _is_system_observation(self.history[-1]["content"])
|
|
418
|
+
):
|
|
419
|
+
self.history.pop()
|
|
420
|
+
removed += 1
|
|
421
|
+
return removed
|
|
422
|
+
|
|
423
|
+
def save_history_pairs(self) -> int:
|
|
424
|
+
return save_history_pairs(self.history, self.tokenizer, self.system_prompt, planner=self.planner)
|
|
425
|
+
|
|
426
|
+
def digest_notes(self) -> int:
|
|
427
|
+
from symbio.llm import digest_notes_to_training
|
|
428
|
+
return digest_notes_to_training(self.tokenizer, self.system_prompt, planner=self.planner)
|
|
429
|
+
|
|
430
|
+
def reload_adapter(self):
|
|
431
|
+
model_type = detect_model_type(self.model)
|
|
432
|
+
ok, reason = can_run_lora(self.config, model_type)
|
|
433
|
+
if not ok:
|
|
434
|
+
print(f" [System] Cannot reload adapter: {reason}")
|
|
435
|
+
return
|
|
436
|
+
self.model, self.tokenizer = load(
|
|
437
|
+
self.config["model_name"], adapter_path=str(ADAPTER_DIR)
|
|
438
|
+
)
|
|
439
|
+
self.adapter_loaded = True
|
symbio/ansi_scanner.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Detect and read ANSI-colored terminal text, especially red error output.
|
|
2
|
+
|
|
3
|
+
This module captures terminal output with ANSI escape codes preserved, then
|
|
4
|
+
parses colour regions. The caller gets both the original text and a list of
|
|
5
|
+
red segments, so the machine can say "the terminal showed this in red".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Iterable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Foreground red codes: 31 (normal red), 91 (bright red).
|
|
18
|
+
_RED_FG = frozenset({"31", "91"})
|
|
19
|
+
# ANSI SGR colour open codes we care about.
|
|
20
|
+
_ANSI_COLOR_RE = re.compile(r"\x1b\[(\d+)m")
|
|
21
|
+
# Strip all ANSI escape sequences.
|
|
22
|
+
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class AnsiScanResult:
|
|
27
|
+
"""Result of scanning a terminal output string."""
|
|
28
|
+
|
|
29
|
+
text: str
|
|
30
|
+
stripped: str
|
|
31
|
+
red_segments: list[str]
|
|
32
|
+
has_red: bool
|
|
33
|
+
error_keywords: list[str]
|
|
34
|
+
looks_bad: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def strip_ansi(text: str) -> str:
|
|
38
|
+
"""Remove all ANSI escape sequences from text."""
|
|
39
|
+
return _ANSI_ESCAPE_RE.sub("", text)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def extract_red_segments(text: str) -> list[str]:
|
|
43
|
+
"""Return plain-text fragments that were rendered in red.
|
|
44
|
+
|
|
45
|
+
Handles stacked/redundant resets. Only SGR colour codes are tracked; we
|
|
46
|
+
ignore bold, italic, cursor movement, etc.
|
|
47
|
+
"""
|
|
48
|
+
segments: list[str] = []
|
|
49
|
+
current: list[str] = []
|
|
50
|
+
in_red = False
|
|
51
|
+
|
|
52
|
+
parts = _ANSI_COLOR_RE.split(text)
|
|
53
|
+
# parts alternates: text, code, text, code, ...
|
|
54
|
+
for i, part in enumerate(parts):
|
|
55
|
+
if i % 2 == 0:
|
|
56
|
+
# Text fragment.
|
|
57
|
+
if in_red:
|
|
58
|
+
current.append(part)
|
|
59
|
+
else:
|
|
60
|
+
# ANSI code number.
|
|
61
|
+
code = part
|
|
62
|
+
if code in _RED_FG:
|
|
63
|
+
in_red = True
|
|
64
|
+
elif code == "0":
|
|
65
|
+
if in_red and current:
|
|
66
|
+
joined = "".join(current)
|
|
67
|
+
if joined.strip():
|
|
68
|
+
segments.append(strip_ansi(joined))
|
|
69
|
+
current = []
|
|
70
|
+
in_red = False
|
|
71
|
+
# Other colour codes are ignored: we only care about red vs reset.
|
|
72
|
+
|
|
73
|
+
# Trailing red text without an explicit reset.
|
|
74
|
+
if in_red and current:
|
|
75
|
+
joined = "".join(current)
|
|
76
|
+
if joined.strip():
|
|
77
|
+
segments.append(strip_ansi(joined))
|
|
78
|
+
|
|
79
|
+
return [s.strip() for s in segments if s.strip()]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_ERROR_KEYWORDS = (
|
|
83
|
+
"error",
|
|
84
|
+
"fatal",
|
|
85
|
+
"failed",
|
|
86
|
+
"traceback",
|
|
87
|
+
"exception",
|
|
88
|
+
"cannot",
|
|
89
|
+
"could not",
|
|
90
|
+
"permission denied",
|
|
91
|
+
"command not found",
|
|
92
|
+
"no such file",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def find_error_keywords(text: str) -> list[str]:
|
|
97
|
+
"""Return error-related keywords found in text (case-insensitive)."""
|
|
98
|
+
low = text.lower()
|
|
99
|
+
return [kw for kw in _ERROR_KEYWORDS if kw in low]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def scan_text(text: str) -> AnsiScanResult:
|
|
103
|
+
"""Analyse a terminal output string."""
|
|
104
|
+
stripped = strip_ansi(text)
|
|
105
|
+
red_segments = extract_red_segments(text)
|
|
106
|
+
keywords = find_error_keywords(stripped)
|
|
107
|
+
return AnsiScanResult(
|
|
108
|
+
text=text,
|
|
109
|
+
stripped=stripped,
|
|
110
|
+
red_segments=red_segments,
|
|
111
|
+
has_red=bool(red_segments),
|
|
112
|
+
error_keywords=keywords,
|
|
113
|
+
looks_bad=bool(red_segments or keywords),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def run_and_scan(
|
|
118
|
+
args: list[str],
|
|
119
|
+
*,
|
|
120
|
+
cwd: str | Path | None = None,
|
|
121
|
+
timeout: float | None = None,
|
|
122
|
+
env: dict[str, str] | None = None,
|
|
123
|
+
) -> tuple[AnsiScanResult, int]:
|
|
124
|
+
"""Run a subprocess and scan its combined stdout/stderr for red text."""
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
args,
|
|
127
|
+
capture_output=True,
|
|
128
|
+
text=False,
|
|
129
|
+
timeout=timeout,
|
|
130
|
+
cwd=str(cwd) if cwd else None,
|
|
131
|
+
env=env,
|
|
132
|
+
)
|
|
133
|
+
raw = result.stdout + b"\n" + result.stderr
|
|
134
|
+
# Decode preserving bytes that produced ANSI codes; replace invalid chars.
|
|
135
|
+
text = raw.decode("utf-8", errors="replace")
|
|
136
|
+
return scan_text(text), result.returncode
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def format_scan_report(result: AnsiScanResult, returncode: int | None = None) -> str:
|
|
140
|
+
"""Return a human/machine-readable summary of a scan."""
|
|
141
|
+
lines: list[str] = []
|
|
142
|
+
if returncode is not None and returncode != 0:
|
|
143
|
+
lines.append(f"Process exited with code {returncode}.")
|
|
144
|
+
if result.has_red:
|
|
145
|
+
lines.append("Red terminal text detected:")
|
|
146
|
+
for seg in result.red_segments:
|
|
147
|
+
lines.append(f" - {seg}")
|
|
148
|
+
if result.error_keywords:
|
|
149
|
+
lines.append("Error keywords found: " + ", ".join(result.error_keywords))
|
|
150
|
+
if not lines:
|
|
151
|
+
lines.append("No red text or error keywords detected.")
|
|
152
|
+
return "\n".join(lines)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def scan_lines(lines: Iterable[str]) -> AnsiScanResult:
|
|
156
|
+
"""Scan multiple lines (e.g. from a log file)."""
|
|
157
|
+
return scan_text("\n".join(lines))
|