Skip to content

Commit 439d017

Browse files
committed
fix(skill): align tool names + add safety gates to chat /skill new
C1: /skill new in chat had only name validation — no wiki-exists check, no wiki-content check, no overwrite guard. The CLI had all three. Extract the gates into _preflight_skill_new and call from both. Add explicit 'remove existing skill first' message in chat (no -y equivalent there). C2: System prompt advertised tool names (list_wiki_dir, read_wiki_file, write_skill_file) that didn't match what was registered with @function_tool (list_wiki, read_wiki, write_skill). LLM saw the registered names; prompt references would confuse it. Rename the wrappers to match. I1: query_wiki was a sync @function_tool calling asyncio.run() on run_query — works only because openai-agents SDK runs sync tools on worker threads. Convert to async @function_tool so the runner awaits it in the same loop, eliminating the nested-asyncio fragility. Add 2 regression tests for the chat safety gates.
1 parent c961e9a commit 439d017

4 files changed

Lines changed: 118 additions & 44 deletions

File tree

openkb/agent/chat.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -521,12 +521,22 @@ async def _handle_slash_skill(arg: str, kb_dir: Path, style: Style) -> None:
521521
name = parts[1]
522522
intent = " ".join(parts[2:])
523523

524-
from openkb.cli import _validate_skill_name
525-
err = _validate_skill_name(name)
524+
# Use the same safety gates as the CLI (name validation, wiki dir,
525+
# wiki content). Chat doesn't have a -y flag, so existing skills
526+
# block with a clear instruction to delete first.
527+
from openkb.cli import _preflight_skill_new
528+
err = _preflight_skill_new(kb_dir, name, yes_flag=False)
526529
if err:
527530
_fmt(style, ("class:error", f"[ERROR] {err}\n"))
528531
return
529532

533+
target = kb_dir / "output" / "skills" / name
534+
if target.exists():
535+
_fmt(style, ("class:error",
536+
f"[ERROR] output/skills/{name}/ already exists. Remove it first "
537+
f"with `rm -rf output/skills/{name}` and re-run.\n"))
538+
return
539+
530540
# Load model from KB config
531541
from openkb.config import load_config, DEFAULT_CONFIG
532542
config = load_config(kb_dir / ".openkb" / "config.yaml")

openkb/agent/skill_compiler.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,15 @@
1313
"""
1414
from __future__ import annotations
1515

16-
import asyncio
1716
from pathlib import Path
1817

1918
from agents import Agent, Runner, function_tool
2019
from agents.model_settings import ModelSettings
2120

2221
from openkb.agent.skill_tools import (
23-
list_wiki_dir,
24-
read_wiki_file_for_skill,
25-
write_skill_file,
22+
list_wiki_dir as _list_wiki_dir_impl,
23+
read_wiki_file_for_skill as _read_wiki_file_impl,
24+
write_skill_file as _write_skill_file_impl,
2625
)
2726
from openkb.prompts import load_prompt
2827
from openkb.schema import get_agents_md
@@ -59,32 +58,31 @@ def build_skill_compile_agent(
5958
)
6059

6160
@function_tool
62-
def list_wiki(directory: str) -> str:
61+
def list_wiki_dir(directory: str) -> str:
6362
"""List .md files in a wiki subdirectory (e.g. 'concepts')."""
64-
return list_wiki_dir(directory, wiki_root)
63+
return _list_wiki_dir_impl(directory, wiki_root)
6564

6665
@function_tool
67-
def read_wiki(path: str) -> str:
66+
def read_wiki_file(path: str) -> str:
6867
"""Read a wiki markdown file by path relative to wiki/ (e.g. 'concepts/attention.md')."""
69-
return read_wiki_file_for_skill(path, wiki_root)
68+
return _read_wiki_file_impl(path, wiki_root)
7069

7170
@function_tool
72-
def query_wiki(question: str) -> str:
71+
async def query_wiki(question: str) -> str:
7372
"""Run a semantic query over the wiki and return the answer.
7473
7574
Use sparingly — this is itself an LLM call. Prefer reading specific
7675
files when you already know which one you want.
7776
"""
78-
# Lazy import to avoid circular dependency at module import.
77+
# Lazy import to avoid a circular dependency at module load time.
7978
from openkb.agent.query import run_query
8079
kb_dir = Path(wiki_root).parent
81-
config_model = model
82-
return asyncio.run(run_query(question, kb_dir, config_model, stream=False))
80+
return await run_query(question, kb_dir, model, stream=False)
8381

8482
@function_tool
85-
def write_skill(path: str, content: str) -> str:
83+
def write_skill_file(path: str, content: str) -> str:
8684
"""Write a file under the skill directory."""
87-
return write_skill_file(path, content, skill_root)
85+
return _write_skill_file_impl(path, content, skill_root)
8886

8987
@function_tool
9088
def done(summary: str) -> str:
@@ -94,7 +92,7 @@ def done(summary: str) -> str:
9492
return Agent(
9593
name="skill-compiler",
9694
instructions=instructions,
97-
tools=[list_wiki, read_wiki, query_wiki, write_skill, done],
95+
tools=[list_wiki_dir, read_wiki_file, query_wiki, write_skill_file, done],
9896
model=f"litellm/{model}",
9997
model_settings=ModelSettings(parallel_tool_calls=False),
10098
)

openkb/cli.py

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,54 @@ def _validate_skill_name(name: str) -> str | None:
165165
return None
166166

167167

168+
def _preflight_skill_new(
169+
kb_dir: Path, name: str, yes_flag: bool,
170+
) -> str | None:
171+
"""Run all the safety gates for ``openkb skill new`` / ``/skill new``.
172+
173+
Returns ``None`` if it's safe to proceed (caller will then either
174+
proceed or, for the overwrite case, call ``_clear_existing_skill_dir``
175+
after confirming with the user). Returns an error message string if
176+
one of the gates trips.
177+
178+
NOTE: This intentionally does NOT handle the overwrite confirmation
179+
itself — that's caller-specific (TTY-based ``click.confirm`` in CLI;
180+
explicit ``--force``-style flag in chat). It only returns an error if
181+
the target dir exists AND ``yes_flag`` is False, and the caller is
182+
expected to detect that error message and prompt as appropriate.
183+
"""
184+
err = _validate_skill_name(name)
185+
if err:
186+
return err
187+
188+
wiki = kb_dir / "wiki"
189+
if not wiki.is_dir():
190+
return (
191+
"No wiki found in this KB. Run `openkb add <source>` to "
192+
"ingest documents first."
193+
)
194+
195+
has_content = any(
196+
(wiki / sub).is_dir() and any((wiki / sub).iterdir())
197+
for sub in ("concepts", "summaries")
198+
)
199+
if not has_content:
200+
return (
201+
"Wiki has no compiled content yet. Ingest at least one "
202+
"document with `openkb add` first."
203+
)
204+
205+
return None
206+
207+
208+
def _clear_existing_skill_dir(kb_dir: Path, name: str) -> None:
209+
"""Delete an existing ``<kb>/output/skills/<name>/`` directory."""
210+
import shutil
211+
target = kb_dir / "output" / "skills" / name
212+
if target.exists():
213+
shutil.rmtree(target)
214+
215+
168216
def add_single_file(file_path: Path, kb_dir: Path) -> Literal["added", "skipped", "failed"]:
169217
"""Convert, index, and compile a single document into the knowledge base.
170218
@@ -1393,54 +1441,31 @@ def skill_new(ctx, name, intent, yes_flag):
13931441
openkb skill new karpathy-thinking "Reason about transformers like Karpathy"
13941442
"""
13951443
import asyncio
1396-
import shutil
13971444
import sys
13981445

13991446
kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override"))
14001447
if kb_dir is None:
14011448
click.echo("No knowledge base found. Run `openkb init` first.", err=True)
14021449
ctx.exit(1)
14031450

1404-
# Validate name
1405-
err = _validate_skill_name(name)
1451+
err = _preflight_skill_new(kb_dir, name, yes_flag)
14061452
if err:
14071453
click.echo(f"[ERROR] {err}", err=True)
1408-
ctx.exit(2)
1409-
1410-
# Validate wiki exists and has content
1411-
wiki = kb_dir / "wiki"
1412-
if not wiki.is_dir():
1413-
click.echo(
1414-
"[ERROR] No wiki found in this KB. Run `openkb add <source>` "
1415-
"to ingest documents first.",
1416-
err=True,
1417-
)
1418-
ctx.exit(1)
1419-
has_content = any(
1420-
(wiki / sub).is_dir() and any((wiki / sub).iterdir())
1421-
for sub in ("concepts", "summaries")
1422-
)
1423-
if not has_content:
1424-
click.echo(
1425-
"[ERROR] Wiki has no compiled content yet. Ingest at least one "
1426-
"document with `openkb add` first.",
1427-
err=True,
1428-
)
14291454
ctx.exit(1)
14301455

1431-
# Overwrite handling
1456+
# Overwrite handling (CLI-specific)
14321457
target = kb_dir / "output" / "skills" / name
14331458
if target.exists():
14341459
if yes_flag:
1435-
shutil.rmtree(target)
1460+
_clear_existing_skill_dir(kb_dir, name)
14361461
elif sys.stdin.isatty():
14371462
if not click.confirm(
14381463
f"output/skills/{name}/ already exists. Overwrite?",
14391464
default=False,
14401465
):
14411466
click.echo("Aborted.")
14421467
ctx.exit(1)
1443-
shutil.rmtree(target)
1468+
_clear_existing_skill_dir(kb_dir, name)
14441469
else:
14451470
click.echo(
14461471
f"[ERROR] output/skills/{name}/ exists. Pass -y to overwrite "

tests/test_skill_chat_slash.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ def _make_kb(tmp_path):
1717
(tmp_path / "wiki" / "concepts").mkdir(parents=True)
1818
(tmp_path / "wiki" / "summaries").mkdir(parents=True)
1919
(tmp_path / "wiki" / "index.md").write_text("# index\n")
20+
# Populate so wiki-content gate accepts
21+
(tmp_path / "wiki" / "concepts" / "demo.md").write_text("# demo\n")
22+
(tmp_path / "wiki" / "summaries" / "demo.md").write_text("# demo\n")
2023
return tmp_path
2124

2225

@@ -62,3 +65,41 @@ async def test_slash_skill_unknown_subcommand(tmp_path):
6265
style = Style.from_dict({})
6366
action = await _handle_slash('/skill list', kb, session, style)
6467
assert action is None
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_slash_skill_new_rejects_empty_wiki(tmp_path):
72+
"""Chat / slash command must catch freshly-init'd KBs (no compiled content)."""
73+
kb = tmp_path
74+
(kb / ".openkb").mkdir()
75+
(kb / ".openkb" / "config.yaml").write_text("model: gpt-4o-mini\n")
76+
(kb / ".openkb" / "chats").mkdir()
77+
# Empty wiki/ — exactly what `openkb init` creates
78+
(kb / "wiki" / "concepts").mkdir(parents=True)
79+
(kb / "wiki" / "summaries").mkdir(parents=True)
80+
(kb / "wiki" / "index.md").write_text("# index\n")
81+
82+
session = ChatSession.new(kb, "gpt-4o-mini", "en")
83+
style = Style.from_dict({})
84+
85+
action = await _handle_slash('/skill new demo "intent"', kb, session, style)
86+
assert action is None
87+
assert not (kb / "output").exists()
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_slash_skill_new_rejects_when_target_exists(tmp_path):
92+
"""Chat / slash command must not silently overwrite an existing skill."""
93+
kb = _make_kb(tmp_path)
94+
(kb / "wiki" / "concepts" / "x.md").write_text("x")
95+
(kb / "wiki" / "summaries" / "x.md").write_text("x")
96+
(kb / "output" / "skills" / "demo").mkdir(parents=True)
97+
(kb / "output" / "skills" / "demo" / "stale.txt").write_text("old")
98+
99+
session = ChatSession.new(kb, "gpt-4o-mini", "en")
100+
style = Style.from_dict({})
101+
102+
action = await _handle_slash('/skill new demo "intent"', kb, session, style)
103+
assert action is None
104+
# stale.txt must still be there (we didn't overwrite)
105+
assert (kb / "output" / "skills" / "demo" / "stale.txt").read_text() == "old"

0 commit comments

Comments
 (0)