diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-25 18:42:40 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-25 18:42:40 +0200 |
| commit | 6ae195baa09bc217d51f3fb0a38312dfdcc85fdb (patch) | |
| tree | f6e0f19af4bb0931fe23cd7cf65381e3e87930d2 | |
| parent | 767636465e43eb9662cde31037392d4b9c05a735 (diff) | |
| download | llamachat-6ae195baa09bc217d51f3fb0a38312dfdcc85fdb.tar.gz llamachat-6ae195baa09bc217d51f3fb0a38312dfdcc85fdb.zip | |
fix: honor skills_enabled on restore, and final-review polish
| -rw-r--r-- | README.md | 5 | ||||
| -rw-r--r-- | llamachat/backend.py | 5 | ||||
| -rw-r--r-- | llamachat/config.py | 2 | ||||
| -rw-r--r-- | llamachat/skills.py | 2 | ||||
| -rw-r--r-- | llamachat/ui.py | 9 | ||||
| -rwxr-xr-x | test_llamachat.py | 61 |
6 files changed, 80 insertions, 4 deletions
@@ -495,6 +495,11 @@ the whole session in chat mode and drop after the reply in one-shot mode. `max_searches` caps the total tool rounds per turn here too, so skills load and searches share the same per-turn budget. +Skill text is injected verbatim into the system prompt. Skills are +user-authored files in a directory you control, trusted the same way the +prompts in `prompts/` are, but treat a skill downloaded from elsewhere like +any other untrusted instructions. + ### External providers llamachat can talk to OpenAI-compatible cloud providers alongside the local diff --git a/llamachat/backend.py b/llamachat/backend.py index 686aa6f..70a98b1 100644 --- a/llamachat/backend.py +++ b/llamachat/backend.py @@ -442,11 +442,14 @@ class Client: return # A tool call for a name that exists nowhere still needs an answer, # or the next request is rejected for an unanswered call. + content = f"error: unknown tool '{name}'" + if last: + content = f"{content}\n\n{skills.NO_MORE_TOOLS}" convo.append( { "role": "tool", "tool_call_id": call["id"], - "content": f"error: unknown tool '{name}'", + "content": content, } ) diff --git a/llamachat/config.py b/llamachat/config.py index f3a2fbb..f7dab5d 100644 --- a/llamachat/config.py +++ b/llamachat/config.py @@ -132,7 +132,7 @@ def load(path: Path = CONFIG_PATH) -> Config: search_url = str(values["search_url"]).rstrip("/") search_enabled = bool(values["search_enabled"]) and bool(search_url) - skills_dir = Path(str(values["skills_dir"])).expanduser() + skills_dir = Path(str(values["skills_dir"])).expanduser().resolve() skills_enabled = bool(values["skills_enabled"]) and skills_dir.is_dir() # Providers are built from the raw values so a bare base_url still diff --git a/llamachat/skills.py b/llamachat/skills.py index e36b66e..5efb854 100644 --- a/llamachat/skills.py +++ b/llamachat/skills.py @@ -88,6 +88,8 @@ class SkillStore: self.dir = Path(directory) def _skills(self) -> list[Skill]: + # ponytail: re-reads the directory on every call so edits are honored + # immediately; add an mtime-keyed cache if the dir ever grows. if not self.dir.is_dir(): return [] found = [] diff --git a/llamachat/ui.py b/llamachat/ui.py index 2d4ded5..58c0156 100644 --- a/llamachat/ui.py +++ b/llamachat/ui.py @@ -1290,7 +1290,8 @@ class ChatWindow(QMainWindow): parts = [self.system_prompt_text()] if self.cfg.search_enabled: parts.append(_date_note()) - parts.extend(_skill_parts(self.skills, self.loaded_skills)) + if self.cfg.skills_enabled: + parts.extend(_skill_parts(self.skills, self.loaded_skills)) text = "\n\n".join(p for p in parts if p) return [{"role": "system", "content": text}] if text else [] @@ -1894,7 +1895,11 @@ class ChatWindow(QMainWindow): self.prompt_custom = _column(session, "prompt_custom") self.select_prompt(_column(session, "prompt_name")) - self.loaded_skills = _skills_list(_column(session, "skills")) + self.loaded_skills = ( + _skills_list(_column(session, "skills")) + if self.cfg.skills_enabled + else [] + ) self._refresh_skill_chips() self.oneshot_radio.blockSignals(True) diff --git a/test_llamachat.py b/test_llamachat.py index d96b599..71a2c35 100755 --- a/test_llamachat.py +++ b/test_llamachat.py @@ -3156,6 +3156,7 @@ def test_date_note(): # keeps its instructions. class _Cfg: search_enabled = True + skills_enabled = False class _Win: cfg = _Cfg() @@ -3183,6 +3184,7 @@ def test_date_note(): class _Off(_Bare): class cfg: search_enabled = False + skills_enabled = False assert _Off._system_messages(_Off()) == [] print("ok date note") @@ -3425,6 +3427,63 @@ def test_skill_round_cap(): print("ok skill round cap") +def test_tools_combined(): + import urllib.request + + from llamachat import search, skills + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write_skills(root, {"finance-cli/SKILL.md": SKILLS_SAMPLE["finance-cli/SKILL.md"]}) + store = skills.SkillStore(root) + + client = backend.Client("http://x") + sent_tools = [] + + def always_tool_calls(model, messages, tools): + sent_tools.append(tools) + yield from _tool_round() + + client._stream_once = always_tool_calls + search_cfg = backend.SearchConfig( + enabled=True, url="http://searx", max_searches=1 + ) + skills_cfg = backend.SkillsConfig(enabled=True, store=store, max_searches=1) + original = _with_urlopen(json.dumps({"results": []}).encode()) + try: + list(client.stream_chat( + "m", [{"role": "user", "content": "hi"}], search_cfg, skills_cfg + )) + finally: + urllib.request.urlopen = original + + # Both schemas are offered together; the shared cap withdraws both + # on the final round. + assert len(sent_tools) == 2, sent_tools + assert sent_tools[0] == [search.TOOL_SCHEMA, store.tool_schema()], sent_tools[0] + assert sent_tools[-1] is None, sent_tools[-1] + print("ok tools combined") + + +def test_skills_directory_truncation(): + from llamachat import skills + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + path = root / "long-desc" / "SKILL.md" + path.parent.mkdir(parents=True) + path.write_text( + "---\nname: long-desc\n" + f"description: {'x' * 300}\n---\n\nbody\n" + ) + store = skills.SkillStore(root) + directory = store.directory() + assert "long-desc" in directory + # The 300-char description is capped for the model's listing. + assert len(directory) < 200, len(directory) + print("ok skills directory truncation") + + def test_title_cleaning(): clean = backend.clean_title @@ -3773,6 +3832,8 @@ if __name__ == "__main__": test_skill_tool_round() test_skill_tool_errors() test_skill_round_cap() + test_tools_combined() + test_skills_directory_truncation() test_title_cleaning() test_title_request() test_needs_title() |
