Author runnable skill bodies via OpenAI-compatible codegen model

create_skill now writes a real predict/act body: the small decision model
still authors title + description (engine.generate), then a larger
OpenAI-compatible model (default qwen38-iq3s) writes the runnable code
against the SKILL.md contract. Bodies persist to data/skills/<cat>/<name>.py,
are hot-loaded via importlib, merged into the running tree, and the request
re-dispatches to the new leaf. The dashboard decision-flow view shows the
title/description with a writing badge while the body is being written.
Codegen failure degrades to a navigable stub.
This commit is contained in:
Denton Social
2026-09-24 00:36:35 -05:00
parent 789ed4ae25
commit 440e49e76e
14 changed files with 910 additions and 18 deletions
+10
View File
@@ -18,6 +18,7 @@ import sys
from pathlib import Path
from .dream import dream as run_dream
from .codegen import CodegenClient
from .engine import EngineConfig, EngineUnavailable, SemIfEngine
from .llm import LLMClient
from .log import DecisionLog
@@ -45,6 +46,14 @@ def build_scheduler(config: dict) -> tuple[Scheduler, dict]:
base_url=config.get("llm", {}).get("base_url", "http://localhost:11434/v1"),
model=config.get("llm", {}).get("model", "qwen2.5:3b"),
)
codegen_cfg = config.get("codegen", {})
codegen = CodegenClient(
base_url=codegen_cfg.get(
"base_url", config.get("llm", {}).get("base_url", "http://localhost:11434/v1")
),
model=codegen_cfg.get("model", "qwen38-iq3s"),
timeout=float(codegen_cfg.get("timeout", 600.0)),
)
log = DecisionLog(config.get("log", "data/decisions.jsonl"))
trace = TraceLog(config.get("trace", "data/runs.jsonl"))
scheduler = Scheduler(
@@ -55,6 +64,7 @@ def build_scheduler(config: dict) -> tuple[Scheduler, dict]:
tau=float(config.get("tau", 0.6)),
max_reentries=int(config.get("max_reentries", 3)),
trace=trace,
codegen=codegen,
)
return scheduler, config
+169
View File
@@ -0,0 +1,169 @@
"""Skill code-body generation through an OpenAI-compatible API.
The decision engine authors a skill's title + description with the small model
(engine.generate). Writing the runnable body is a separate step: a larger
OpenAI-compatible model (e.g. qwen38-iq3s on ollama) is prompted with the
SKILL.md contract plus the request and the existing tree, and must reply with
valid Python implementing `predict` / `act`. Stdlib-only HTTP, mirroring
`llm.py`.
"""
from __future__ import annotations
import ast
import json
import urllib.error
import urllib.request
from pathlib import Path
from .decisions import Request
from .skills import SkillDraft, tree_summary
DEFAULT_CONTRACT = Path(__file__).resolve().parent.parent / "SKILL.md"
class CodegenError(RuntimeError):
"""The codegen endpoint could not be reached."""
def read_skill_contract(path: str | None = None) -> str:
contract = Path(path) if path else DEFAULT_CONTRACT
if not contract.is_file():
raise CodegenError(f"skill contract not found: {contract}")
return contract.read_text()
class CodegenClient:
"""Minimal OpenAI-compatible chat client for writing skill bodies."""
def __init__(self, base_url: str, model: str, timeout: float = 600.0):
self.base_url = base_url.rstrip("/")
self.model = model
self.timeout = timeout
def chat(self, messages: list[dict], max_tokens: int = 2048, temperature: float = 0.0) -> str:
url = f"{self.base_url}/chat/completions"
body = json.dumps(
{
"model": self.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
).encode("utf-8")
request = urllib.request.Request(
url, data=body, headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.URLError as exc:
raise CodegenError(
f"codegen endpoint unreachable at {url}: {exc}. Is your local server running?"
) from exc
return payload["choices"][0]["message"]["content"]
def build_skill_body_prompt(
request: Request,
category: str,
draft: SkillDraft,
tree: dict,
contract: str,
) -> list[dict]:
"""Messages for the code-generation model.
The small model already chose the title + description; the big model only
writes the runnable body against the SKILL.md contract, informed by the
request, the category, and the existing skills so it avoids duplication.
"""
existing = ", ".join(s.name for s in tree.get(category, [])) or "(none)"
system = (
"You write runnable skill bodies for a local agent. The contract below "
"is authoritative: follow it exactly.\n\n"
f"{contract}"
)
user = (
f"Request: {request.text}\n"
f"Category: {category}\n"
f"Skill name: {draft.name}\n"
f"Skill description: {draft.description}\n"
f"Existing skills in this category: {existing}\n"
f"Existing categories:\n{tree_summary(tree)}\n"
"Write the Python module body now. Reply with ONLY valid Python code "
"defining `predict` and `act`. No prose, no markdown fences, no JSON."
)
return [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
def parse_skill_body(raw: str) -> str:
"""Extract and validate a Python skill body from the model's reply.
Accepts bare code, ```fenced``` code, or JSON {"code": "..."}. The body
must parse and must define module-level `predict` and `act` functions.
Returns the cleaned source. Raises ValueError otherwise.
"""
text = raw.strip()
if "code" in text[:400] and "{" in text and "}" in text:
start, end = text.find("{"), text.rfind("}")
try:
parsed = json.loads(text[start : end + 1])
candidate = parsed.get("code")
if isinstance(candidate, str):
text = candidate.strip()
except (ValueError, AttributeError):
pass
for fence in ("```python", "```py", "```"):
start = text.find(fence)
if start != -1:
text = text[start + len(fence):]
close = text.rfind("```")
if close != -1:
text = text[:close]
break
text = text.strip()
if not text:
raise ValueError("skill body is empty")
try:
module = ast.parse(text, filename="<generated>")
except SyntaxError as exc:
raise ValueError(f"skill body is not valid Python: {exc}") from exc
names = {node.name for node in module.body if isinstance(node, ast.FunctionDef)}
for required in ("predict", "act"):
if required not in names:
raise ValueError(f"skill body must define a module-level `{required}` function")
return text
def generate_skill_body(
client: CodegenClient,
request: Request,
category: str,
draft: SkillDraft,
tree: dict,
contract: str | None = None,
max_tokens: int = 2048,
) -> str:
"""Author a skill body with the big model; retries once on invalid output."""
contract_text = contract if contract is not None else read_skill_contract()
messages = build_skill_body_prompt(request, category, draft, tree, contract_text)
last_error: Exception | None = None
for attempt in range(2):
try:
raw = client.chat(messages, max_tokens=max_tokens)
return parse_skill_body(raw)
except ValueError as exc:
last_error = exc
messages = messages + [
{
"role": "user",
"content": (
f"That was rejected: {exc}. Reply with ONLY the Python code "
"now — no prose, no fences, no JSON."
),
}
]
raise ValueError(f"skill body rejected twice: {last_error}")
+91 -8
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from .codegen import CodegenClient, CodegenError, generate_skill_body
from .decisions import DecisionRequest, Option, Request
from .engine import SemIfEngine
from .llm import LLMClient
@@ -21,12 +22,15 @@ from .skills import (
CreateCategory,
CreateSkill,
Skill,
SkillBodyStore,
build_skills,
build_tree,
compose_state,
generate_category,
generate_skill,
materialize_skill,
merge_registry,
merge_skill_bodies,
navigate,
)
from .trace import TraceLog
@@ -55,6 +59,7 @@ class DispatchResult:
summary: str
skill: str | None = None
decisions_logged: int = 0
body_written: bool = False
class Scheduler:
@@ -67,6 +72,7 @@ class Scheduler:
tau: float = 0.6,
max_reentries: int = 3,
trace: TraceLog | None = None,
codegen: CodegenClient | None = None,
):
self.engine = engine
self.llm = llm
@@ -75,6 +81,7 @@ class Scheduler:
self.config = config
self.tau = tau
self.max_reentries = max_reentries
self.codegen = codegen
self.queue = UrgencyQueue(
max_size=int(config.get("queue", {}).get("max_size", 100)),
age_rate=float(config.get("queue", {}).get("age_rate", 0.0)),
@@ -82,7 +89,9 @@ class Scheduler:
self.skills = build_skills(config)
self.tree = build_tree(self.skills)
self.registry = CategoryRegistry(config.get("category_registry", "data/categories.json"))
self.body_store = SkillBodyStore(config.get("skill_bodies", "data/skills"))
merge_registry(self.tree, self.registry.read())
merge_skill_bodies(self.tree, self.body_store, self.registry.read())
self.ctx = ActionContext(engine=self.engine, config=config)
self.runner = SkillRunner(self.ctx, self.llm, self.log)
self.current: Process | None = None
@@ -210,22 +219,32 @@ class Scheduler:
# ---- dispatch ----
def _dispatch(self, request: Request) -> DispatchResult:
def _dispatch(self, request: Request, _depth: int = 0) -> DispatchResult:
navigation = navigate(self.engine, self.log, self.trace, request, self.tree)
if isinstance(navigation, CreateCategory):
return self._create_category(request)
if isinstance(navigation, CreateSkill):
return self._create_skill(request, navigation.category)
outcome = self.runner.run(navigation, request)
created = self._create_skill(request, navigation.category)
if created.kind == "create_skill" and created.body_written and _depth < self.max_reentries:
requeued = _requeue(request, request.text)
self.trace.append(
"requeued", request.id, text=request.text, reason="skill created"
)
return self._dispatch(requeued, _depth=_depth + 1)
return created
return self._run_skill(navigation, request)
def _run_skill(self, skill: Skill, request: Request) -> DispatchResult:
outcome = self.runner.run(skill, request)
if outcome.error:
self.trace.append(
"error", request.id, skill=navigation.name, message=outcome.error
"error", request.id, skill=skill.name, message=outcome.error
)
return DispatchResult(kind="error", summary=f"skill error: {outcome.error}")
self.trace.append(
"assessed",
request.id,
skill=navigation.name,
skill=skill.name,
success=outcome.success,
summary=outcome.summary,
updated_request=outcome.updated_request,
@@ -235,8 +254,8 @@ class Scheduler:
self.trace.append("requeued", request.id, text=outcome.updated_request)
return DispatchResult(
kind="ran",
summary=f"{navigation.name}: {'ok' if outcome.success else 'failed'}{outcome.summary}",
skill=navigation.name,
summary=f"{skill.name}: {'ok' if outcome.success else 'failed'}{outcome.summary}",
skill=skill.name,
decisions_logged=outcome.decisions_logged,
)
@@ -275,7 +294,14 @@ class Scheduler:
)
def _create_skill(self, request: Request, category: str) -> DispatchResult:
"""Author a new skill leaf stub with the decision model in generation mode."""
"""Author a new skill leaf with the decision model in generation mode.
The small model writes the title + description; a larger OpenAI-
compatible model then writes the runnable body against SKILL.md. The
stub is registered first so the leaf is navigable even if the body
write fails; a successful write is merged into the tree as a runnable
skill and the request re-dispatches to it.
"""
from .engine import EngineUnavailable
try:
@@ -296,21 +322,78 @@ class Scheduler:
kind="error",
summary=f"create_skill failed: {draft.name} already exists",
)
self.registry.register_skill(category, draft.name, draft.description)
self.tree.setdefault(category, []).append(
Skill(name=draft.name, category=category, description=draft.description)
)
self.trace.append(
"skill_writing",
request.id,
category=category,
skill=draft.name,
description=draft.description,
model=self.codegen.model if self.codegen else None,
)
if self.codegen is None:
self.trace.append(
"skill_created",
request.id,
category=category,
skill=draft.name,
description=draft.description,
body=None,
written=False,
)
return DispatchResult(
kind="create_skill",
summary=f"created stub {category}.{draft.name}: {draft.description} (no codegen configured)",
skill=draft.name,
)
try:
draft.code = generate_skill_body(
self.codegen, request, category, draft, self.tree
)
skill = materialize_skill(draft, category, self.body_store)
except (CodegenError, ValueError) as exc:
self.trace.append(
"error",
request.id,
phase="create_skill",
category=category,
message=f"skill body write failed: {exc}",
)
return DispatchResult(
kind="create_skill",
summary=f"created stub {category}.{draft.name}: {draft.description} (body write failed: {exc})",
skill=draft.name,
)
skills = self.tree.setdefault(category, [])
for index, existing in enumerate(skills):
if existing.name == draft.name:
skills[index] = skill
break
else:
skills.append(skill)
skills.sort(key=lambda s: s.name)
body_path = self.body_store.body_path(category, draft.name).as_posix()
self.trace.append(
"skill_created",
request.id,
category=category,
skill=draft.name,
description=draft.description,
body=body_path,
written=True,
)
return DispatchResult(
kind="create_skill",
summary=f"created skill {category}.{draft.name}: {draft.description}",
skill=draft.name,
body_written=True,
)
def status(self) -> str:
+109
View File
@@ -12,6 +12,7 @@ Only the real skills live here; navigation uses the real decision engine.
from __future__ import annotations
import importlib.util
import json
import os
import re
@@ -95,6 +96,7 @@ class SkillDraft:
name: str
description: str
code: str = ""
class CategoryRegistry:
@@ -130,6 +132,113 @@ class CategoryRegistry:
self.path.write_text(json.dumps(categories, indent=2) + "\n")
class SkillBodyStore:
"""Persists runnable skill bodies as one Python file per skill.
Layout: <base>/<category>/<name>.py. Bodies are written by the codegen step
and loaded back at startup so skills stay runnable across restarts.
"""
def __init__(self, path: str = "data/skills"):
self.path = Path(path)
def write(self, category: str, name: str, code: str) -> Path:
directory = self.path / category
directory.mkdir(parents=True, exist_ok=True)
target = directory / f"{name}.py"
target.write_text(code.rstrip() + "\n")
return target
def body_path(self, category: str, name: str) -> Path:
return self.path / category / f"{name}.py"
def list_bodies(self) -> list[tuple[str, str]]:
if not self.path.is_dir():
return []
bodies = []
for directory in sorted(self.path.iterdir()):
if not directory.is_dir():
continue
for module in sorted(directory.glob("*.py")):
bodies.append((directory.name, module.stem))
return bodies
def load_skill_module(category: str, name: str, base: str = "data/skills"):
"""Import a persisted skill body and return its module."""
path = Path(base) / category / f"{name}.py"
module_name = f"_skill_{category}_{name}".replace("-", "_")
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise ValueError(f"cannot load skill module: {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def materialize_skill(
draft: SkillDraft, category: str, store: SkillBodyStore
) -> Skill:
"""Persist the draft's code body and build a runnable Skill from it."""
if not draft.code:
raise ValueError(f"skill {draft.name} has no code body to materialize")
store.write(category, draft.name, draft.code)
try:
module = load_skill_module(category, draft.name, store.path)
except Exception as exc:
raise ValueError(f"skill {category}.{draft.name} body failed to import: {exc}") from exc
if not callable(getattr(module, "predict", None)) or not callable(
getattr(module, "act", None)
):
raise ValueError(f"skill {category}.{draft.name} body must define predict and act")
return Skill(
name=draft.name,
category=category,
description=draft.description,
predict=module.predict,
act=module.act,
)
def merge_skill_bodies(
tree: dict[str, list[Skill]], store: SkillBodyStore, registry: dict[str, dict]
) -> int:
"""Upgrade persisted skill bodies in the tree to runnable skills.
A body file makes a stub leaf executable; where the registry entry was lost
(or never written), the category is created and the description falls back
to the skill name. Returns the number of skills made runnable.
"""
upgraded = 0
for category, name in store.list_bodies():
description = ""
entry = registry.get(category, {})
for skill in entry.get("skills", []):
if skill.get("name") == name:
description = skill.get("description", "")
try:
module = load_skill_module(category, name, store.path)
except Exception:
continue
skill = Skill(
name=name,
category=category,
description=description or name,
predict=module.predict,
act=module.act,
)
skills = tree.setdefault(category, [])
for index, existing in enumerate(skills):
if existing.name == name:
skills[index] = skill
break
else:
skills.append(skill)
skills.sort(key=lambda s: s.name)
upgraded += 1
return upgraded
def compose_state(request: Request, current: str | None = None) -> str:
parts = [request.text]
if current:
+35
View File
@@ -121,6 +121,11 @@ function eventRow(evt) {
div.textContent = `queued (${evt.label})`;
} else if (evt.kind === "preempted") {
div.textContent = `preempted ${evt.preempted}`;
} else if (evt.kind === "skill_writing") {
div.textContent = `writing skill ${evt.skill}${evt.description} (${evt.model || "codegen"})`;
} else if (evt.kind === "skill_created") {
div.textContent = `created ${evt.skill}${evt.written ? " (body written)" : " (stub)"}`;
div.title = evt.body || evt.description || "";
}
return div;
}
@@ -327,6 +332,36 @@ function eventNode(evt) {
body.textContent = `interrupted ${evt.preempted}, requeued with state`;
} else if (evt.kind === "dropped") {
body.textContent = evt.reason || "";
} else if (evt.kind === "skill_writing") {
node.classList.add("writing");
const title = document.createElement("div");
title.className = "skill-title";
title.textContent = evt.skill;
body.appendChild(title);
const desc = document.createElement("div");
desc.className = "muted";
desc.textContent = evt.description || "";
body.appendChild(desc);
const badge = document.createElement("span");
badge.className = "writing-badge";
badge.textContent = "writing skill body…";
node.appendChild(badge);
} else if (evt.kind === "skill_created") {
const title = document.createElement("div");
title.className = "skill-title";
title.textContent = evt.skill;
body.appendChild(title);
const desc = document.createElement("div");
desc.className = "muted";
desc.textContent = evt.description || "";
body.appendChild(desc);
if (evt.body) {
const p = document.createElement("div");
p.className = "muted";
p.textContent = `body: ${evt.body}`;
node.appendChild(p);
}
node.classList.add(evt.written ? "ok" : "stub");
} else {
body.textContent = evt.summary || evt.text || "";
}
+20
View File
@@ -283,6 +283,26 @@ select {
.node.event-node .evt-kind { text-transform: uppercase; color: var(--muted); font-size: 10px; }
.node.event-node .skill-title { font-weight: 700; }
.node.event-node.writing { border-left: 3px solid var(--warn); }
.node.event-node.stub { border-left: 3px solid var(--muted); }
.writing-badge {
display: inline-block;
margin-top: 8px;
padding: 2px 8px;
border: 1px solid var(--warn);
border-radius: 10px;
color: var(--warn);
font-size: 10px;
animation: writing-pulse 1.2s ease-in-out infinite;
}
@keyframes writing-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* ---------- inspector / tree / status ---------- */
.right { border-right: none; }