aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-25 18:42:40 +0200
committerDanilo M. <danix@danix.xyz>2026-08-25 18:42:40 +0200
commit6ae195baa09bc217d51f3fb0a38312dfdcc85fdb (patch)
treef6e0f19af4bb0931fe23cd7cf65381e3e87930d2
parent767636465e43eb9662cde31037392d4b9c05a735 (diff)
downloadllamachat-6ae195baa09bc217d51f3fb0a38312dfdcc85fdb.tar.gz
llamachat-6ae195baa09bc217d51f3fb0a38312dfdcc85fdb.zip
fix: honor skills_enabled on restore, and final-review polish
-rw-r--r--README.md5
-rw-r--r--llamachat/backend.py5
-rw-r--r--llamachat/config.py2
-rw-r--r--llamachat/skills.py2
-rw-r--r--llamachat/ui.py9
-rwxr-xr-xtest_llamachat.py61
6 files changed, 80 insertions, 4 deletions
diff --git a/README.md b/README.md
index 77ce895..349c21d 100644
--- a/README.md
+++ b/README.md
@@ -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()