Add basic version: SemIf decision engine, urgency queue, skill tree, skill loop, decision log, dream pass, CLI

This commit is contained in:
semif-agent
2026-09-23 03:01:45 -05:00
commit 4e0e0534c4
21 changed files with 1629 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""Semif agent: a local desktop agent whose control flow is one decision model (SemIf)."""
__version__ = "0.1.0"
+158
View File
@@ -0,0 +1,158 @@
"""CLI entrypoint: interactive REPL, scripted JSONL mode, and subcommands.
Run on the box with SemIf + a GGUF + a local OpenAI-compatible server:
python -m semif_agent.cli run # REPL
python -m semif_agent.cli run --script inputs.jsonl
python -m semif_agent.cli dream # prediction-observation cost report
python -m semif_agent.cli skills
python -m semif_agent.cli status
python -m semif_agent.cli relabel <id> <outcome>
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from .dream import dream as run_dream
from .engine import EngineConfig, EngineUnavailable, SemIfEngine
from .llm import LLMClient
from .log import DecisionLog
from .scheduler import Scheduler
from .skills import build_skills, build_tree, tree_summary
def load_config(path: str = "config.json") -> dict:
return json.loads(Path(path).read_text())
def build_scheduler(config: dict) -> tuple[Scheduler, dict]:
engine = SemIfEngine(
EngineConfig(
backend=config.get("engine", {}).get("backend", "llamacpp"),
source=config.get("engine", {}).get("source", ""),
revision=config.get("engine", {}).get("revision", ""),
gguf=config.get("engine", {}).get("gguf", ""),
context_tokens=int(config.get("engine", {}).get("context_tokens", 4096)),
threads=config.get("engine", {}).get("threads"),
)
)
llm = LLMClient(
base_url=config.get("llm", {}).get("base_url", "http://localhost:11434/v1"),
model=config.get("llm", {}).get("model", "qwen2.5:3b"),
)
log = DecisionLog(config.get("log", "data/decisions.jsonl"))
scheduler = Scheduler(
engine=engine,
llm=llm,
log=log,
config=config,
tau=float(config.get("tau", 0.6)),
max_reentries=int(config.get("max_reentries", 3)),
)
return scheduler, config
def try_warm(scheduler: Scheduler) -> str:
try:
scheduler.engine._ensure_loaded()
return "decision engine loaded."
except EngineUnavailable as exc:
return f"decision engine unavailable: {exc}"
def repl(scheduler: Scheduler, config: dict) -> None:
print(try_warm(scheduler))
print("type a request, or one of: busy <text> | idle | status | skills | dream | relabel <id> <outcome> | quit")
while True:
try:
line = input("> ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not line:
continue
lower = line.lower()
if lower in ("quit", "exit"):
break
if lower == "status":
print(scheduler.status())
continue
if lower == "skills":
print(tree_summary(build_tree(build_skills(config))))
continue
if lower == "dream":
print(run_dream(scheduler.log).render())
continue
if lower.startswith("relabel "):
parts = line.split()
if len(parts) != 3:
print("usage: relabel <id> <outcome>")
continue
ok = scheduler.log.relabel(parts[1], parts[2])
print("relabeled." if ok else f"no row with id {parts[1]}")
continue
if lower == "idle":
scheduler.idle()
print("current process cleared.")
continue
if lower.startswith("busy "):
scheduler.busy(line[5:].strip())
print("current process set (busy).")
continue
status, detail = scheduler.submit(line)
print(f"[{status}] {detail}")
def scripted(scheduler: Scheduler, path: str) -> None:
print(try_warm(scheduler))
rows = [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
for row in rows:
status, detail = scheduler.submit(str(row["text"]), source=row.get("source", "scripted"))
print(f"[{status}] {detail}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="semif-agent")
parser.add_argument("--config", default="config.json")
sub = parser.add_subparsers(dest="command")
run_p = sub.add_parser("run", help="interactive REPL or scripted input")
run_p.add_argument("--script", default=None, help="JSONL file of {\"text\": ...} rows")
sub.add_parser("dream", help="compute the prediction-observation cost report")
sub.add_parser("skills", help="list the skill tree")
sub.add_parser("status", help="show current process and queue")
relabel_p = sub.add_parser("relabel", help="human override of a decision label")
relabel_p.add_argument("id")
relabel_p.add_argument("outcome")
args = parser.parse_args(argv)
config = load_config(args.config)
scheduler, config = build_scheduler(config)
if args.command == "run":
if args.script:
scripted(scheduler, args.script)
else:
repl(scheduler, config)
elif args.command == "dream":
print(run_dream(scheduler.log).render())
elif args.command == "skills":
print(tree_summary(build_tree(build_skills(config))))
elif args.command == "status":
print(scheduler.status())
elif args.command == "relabel":
ok = scheduler.log.relabel(args.id, args.outcome)
print("relabeled." if ok else f"no row with id {args.id}")
else:
parser.print_help()
return 0
if __name__ == "__main__":
sys.exit(main())
+85
View File
@@ -0,0 +1,85 @@
"""The SemIf decision contract shared across the agent.
A decision is a typed question over a state with declared options; SemIf returns
probabilities conditional on exactly the supplied options. These are not
calibrated confidence values, so callers treat them as conditional scores.
"""
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass, field
from typing import Any
@dataclass
class Option:
id: str
description: str
@dataclass
class DecisionRequest:
"""One SemIf decision: state + question + typed options."""
state: str
question: str
options: list[Option]
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
def to_semif_row(self) -> dict:
return {
"id": self.id,
"state": self.state,
"question": self.question,
"options": [{"id": o.id, "description": o.description} for o in self.options],
}
@dataclass
class DecisionResult:
"""The outcome of one SemIf call: probabilities aligned to option ids."""
request: DecisionRequest
option_ids: list[str]
probabilities: list[float]
extra: dict[str, Any] = field(default_factory=dict)
@property
def probs(self) -> dict[str, float]:
return dict(zip(self.option_ids, self.probabilities))
def prob(self, option_id: str) -> float:
index = self.option_ids.index(option_id)
return self.probabilities[index]
@property
def selected(self) -> str:
return max(self.probs, key=self.probs.get)
@dataclass
class Request:
"""An incoming input to the agent, before it is gated/scored."""
text: str
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
source: str = "typed"
meta: dict[str, Any] = field(default_factory=dict)
received_at: float = field(default_factory=time.time)
priority: float = 0.5
reentries: int = 0
resume: dict[str, Any] = field(default_factory=dict)
def copy_for_requeue(self) -> "Request":
return Request(
text=self.text,
id=self.id,
source=self.source,
meta=dict(self.meta),
received_at=self.received_at,
priority=self.priority,
reentries=self.reentries + 1,
resume=dict(self.resume),
)
+123
View File
@@ -0,0 +1,123 @@
"""The dream pass: compute the prediction-observation cost over the decision log.
Replays `decisions.jsonl` and reports the training signal for the fine-tuning
step: per-row negative log likelihood (NLL) of the observed outcome under the
predicted distribution, overall cross-entropy, accuracy, and binned expected
calibration error (ECE). Human-override labels are weighted higher, matching
the design (self-assessment + human overrides). This computes the signal; the
actual fine-tuning and CI/CD model swap are a separate v2 step.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from .log import DecisionLog
HUMAN_WEIGHT = 3.0
SELF_WEIGHT = 1.0
@dataclass
class RowCost:
decision_id: str
predicted: float
observed: str
selected: str
nll: float
weight: float
correct: bool
@dataclass
class DreamReport:
rows: list[RowCost] = field(default_factory=list)
skipped: int = 0
human_overrides: int = 0
@property
def cross_entropy(self) -> float | None:
if not self.rows:
return None
total = sum(r.weight for r in self.rows)
return sum(r.weight * r.nll for r in self.rows) / total
@property
def accuracy(self) -> float | None:
if not self.rows:
return None
return sum(r.correct for r in self.rows) / len(self.rows)
@property
def ece(self) -> float | None:
"""Binned expected calibration error over confidence of the selected option."""
if not self.rows:
return None
bins: dict[int, list[RowCost]] = {index: [] for index in range(10)}
for row in self.rows:
bin_index = min(int(row.predicted * 10), 9)
bins[bin_index].append(row)
total_weight = sum(r.weight for r in self.rows)
if total_weight <= 0:
return None
error = 0.0
for index, members in bins.items():
if not members:
continue
weight = sum(r.weight for r in members)
confidence = sum(r.predicted * r.weight for r in members) / weight
accuracy = sum(r.correct * r.weight for r in members) / weight
error += abs(confidence - accuracy) * weight / total_weight
return error
def render(self) -> str:
lines = [
f"decision rows: {len(self.rows)} (skipped {self.skipped})",
f"human overrides: {self.human_overrides}",
]
ce = self.cross_entropy
acc = self.accuracy
ece = self.ece
lines.append(f"cross-entropy (weighted): {ce:.4f}" if ce is not None else "cross-entropy: n/a")
lines.append(f"accuracy (selected==observed): {acc:.3f}" if acc is not None else "accuracy: n/a")
lines.append(f"ECE (10 bins): {ece:.4f}" if ece is not None else "ECE: n/a")
if self.rows:
worst = sorted(self.rows, key=lambda r: r.nll, reverse=True)[:5]
lines.append("highest-cost rows:")
for row in worst:
lines.append(
f" {row.decision_id} pred={row.predicted:.3f} selected={row.selected} "
f"observed={row.observed} nll={row.nll:.3f}"
)
return "\n".join(lines)
def dream(log: DecisionLog) -> DreamReport:
report = DreamReport()
for row in log.read():
probs = row.get("predicted_probs") or {}
observed = row.get("observed_outcome")
selected = row.get("selected")
if observed is None or selected is None or observed not in probs:
report.skipped += 1
continue
predicted = float(probs[observed])
if predicted <= 0:
predicted = 1e-9
nll = -math.log(predicted)
weight = HUMAN_WEIGHT if row.get("label_source") == "human" else SELF_WEIGHT
if row.get("label_source") == "human":
report.human_overrides += 1
report.rows.append(
RowCost(
decision_id=row["id"],
predicted=predicted,
observed=observed,
selected=selected,
nll=nll,
weight=weight,
correct=(selected == observed),
)
)
return report
+93
View File
@@ -0,0 +1,93 @@
"""Real SemIf decision engine (no mocking).
Wraps the SemIf package (`semif_phase1`). The import and model load happen
lazily so the rest of the agent is pure stdlib and testable without SemIf
installed. On hardware that is neither CUDA nor Apple, use the shipped
llama.cpp backend: a local GGUF scored on CPU (or Vulkan if your llama.cpp
build enables it). This engine is only usable on a machine with SemIf and the
pinned GGUF available; elsewhere calls raise EngineUnavailable.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from .decisions import DecisionRequest, DecisionResult
class EngineUnavailable(RuntimeError):
"""SemIf is not installed or the configured model is missing."""
@dataclass
class EngineConfig:
backend: str = "llamacpp"
source: str = "Qwen/Qwen3.5-4B"
revision: str = ""
gguf: str = ""
context_tokens: int = 4096
threads: int | None = None
class SemIfEngine:
"""One pinned SemIf model, loaded once and used for every decision."""
def __init__(self, config: EngineConfig):
self.config = config
self._model = None
self._tokenizer = None
self._metadata = None
def _ensure_loaded(self) -> tuple:
if self._model is not None:
return self._model, self._tokenizer, self._metadata
if self.config.backend != "llamacpp":
raise EngineUnavailable(
f"Unsupported backend {self.config.backend!r}; use 'llamacpp'."
)
if not self.config.gguf or not Path(self.config.gguf).is_file():
raise EngineUnavailable(
f"GGUF not found: {self.config.gguf!r}. Set engine.gguf in config.json."
)
try:
from semif_phase1 import llamacpp_backend as backend
except ImportError as exc:
raise EngineUnavailable(
"SemIf is not installed here. Install it on the target box with "
"`pip install -e '.[test,llamacpp]'`."
) from exc
try:
model, tokenizer, metadata = backend.load_model(
self.config.source,
self.config.revision,
self.config.gguf,
threads=self.config.threads,
context_tokens=self.config.context_tokens,
)
except Exception as exc:
raise EngineUnavailable(f"Failed to load the SemIf model: {exc}") from exc
self._model, self._tokenizer, self._metadata = model, tokenizer, metadata
return model, tokenizer, metadata
@property
def loaded(self) -> bool:
return self._model is not None
def call(self, request: DecisionRequest) -> DecisionResult:
model, tokenizer, metadata = self._ensure_loaded()
from semif_phase1 import llamacpp_backend as backend
row = request.to_semif_row()
result = backend.score(model, tokenizer, row, metadata)
return DecisionResult(
request=request,
option_ids=list(result["option_ids"]),
probabilities=list(result["probabilities"]),
extra={
"prompt_sha256": result.get("prompt_sha256"),
"input_tokens": result.get("input_tokens"),
"forward_seconds": result.get("forward_seconds"),
"total_seconds": result.get("total_seconds"),
},
)
+86
View File
@@ -0,0 +1,86 @@
"""Self-assessment LLM client.
Talks to a local OpenAI-compatible server (e.g. ollama, llama.cpp server) for
the observe -> assess phase of the skill loop. Real, not mocked; the endpoint
must be reachable. Uses only the stdlib HTTP client so the core stays
dependency-free.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from typing import Any
@dataclass
class Assessment:
success: bool
summary: str
updated_request: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
class LLMClient:
def __init__(self, base_url: str, model: str, timeout: float = 120.0):
self.base_url = base_url.rstrip("/")
self.model = model
self.timeout = timeout
def _chat(self, messages: list[dict], temperature: float = 0.0) -> str:
url = f"{self.base_url}/chat/completions"
body = json.dumps(
{"model": self.model, "messages": messages, "temperature": temperature}
).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 RuntimeError(
f"LLM endpoint unreachable at {url}: {exc}. Is your local server running?"
) from exc
return payload["choices"][0]["message"]["content"]
def assess(self, skill: str, request_text: str, action_log: str) -> Assessment:
system = (
"You are the self-assessment step of an agent skill run. Decide whether "
"the skill achieved its goal. Reply with JSON only: "
'{"success": true|false, "summary": "<brief>", "updated_request": '
'"<requeued request text or null>"}. success is true only if the goal was met.'
)
user = (
f"Skill: {skill}\n"
f"Goal request: {request_text}\n"
f"What was done:\n{action_log}\n"
)
try:
raw = self._chat(
[
{"role": "system", "content": system},
{"role": "user", "content": user},
]
)
parsed = self._parse_json(raw)
except Exception as exc:
return Assessment(success=False, summary=f"assessment failed: {exc}")
success = bool(parsed.get("success"))
summary = str(parsed.get("summary", ""))
updated = parsed.get("updated_request")
return Assessment(
success=success,
summary=summary,
updated_request=None if updated is None else str(updated),
)
@staticmethod
def _parse_json(raw: str) -> dict:
text = raw.strip()
start, end = text.find("{"), text.rfind("}")
if start != -1 and end != -1:
text = text[start : end + 1]
return json.loads(text)
+78
View File
@@ -0,0 +1,78 @@
"""Decision logging: every SemIf call is written as a labeled training row.
Rows match the SemIf `decisions.jsonl` shape plus the prediction-observation
cost fields. `observed_outcome` is the label for the cost function; by default
it is the option that was actually selected (self-consistent), and a human
override can relabel a row to the correct outcome with a higher weight.
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from .decisions import DecisionRequest, DecisionResult
class DecisionLog:
def __init__(self, path: str = "data/decisions.jsonl"):
self.path = Path(path)
def append(
self,
request: DecisionRequest,
result: DecisionResult,
label: str | None = None,
extra: dict | None = None,
) -> None:
"""Append one decision row. `label` overrides observed_outcome."""
observed = label if label is not None else result.selected
source = "human" if label is not None else "self"
row = {
"id": request.id,
"ts": time.time(),
"state": request.state,
"question": request.question,
"options": [{"id": o.id, "description": o.description} for o in request.options],
"predicted_probs": result.probs,
"selected": result.selected,
"observed_outcome": observed,
"label_source": source,
}
if extra:
row["extra"] = extra
self.path.parent.mkdir(parents=True, exist_ok=True)
with self.path.open("a") as handle:
handle.write(json.dumps(row) + "\n")
def relabel(self, decision_id: str, observed: str) -> bool:
"""Human override: set a corrected observed outcome for one row."""
rows = self.read()
found = False
for row in rows:
if row["id"] == decision_id:
row["observed_outcome"] = observed
row["label_source"] = "human"
found = True
if not found:
return False
self._write(rows)
return True
def read(self) -> list[dict]:
if not self.path.is_file():
return []
rows = []
with self.path.open("r") as handle:
for line in handle:
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
def _write(self, rows: list[dict]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
with self.path.open("w") as handle:
for row in rows:
handle.write(json.dumps(row) + "\n")
+83
View File
@@ -0,0 +1,83 @@
"""Urgency priority queue.
Items are sorted by descending urgency weight, then FIFO arrival, then most
recent first. Queued items age upward so they cannot starve, and the queue has
a maximum depth.
"""
from __future__ import annotations
import heapq
import itertools
import time
from dataclasses import dataclass
from typing import Iterator
from .decisions import Request
MIN_RECENCY = 0.0
@dataclass
class QueueItem:
weight: float
seq: int
recency: float
request: Request
def key(self) -> tuple:
return (-self.weight, self.seq)
class UrgencyQueue:
"""A max-by-urgency priority queue with FIFO/recency tie-breaking."""
def __init__(self, max_size: int = 100, age_rate: float = 0.0):
self.max_size = max_size
self.age_rate = age_rate
self._entries: list[tuple] = []
self._counter = itertools.count()
def push(self, request: Request, weight: float, recency: float | None = None) -> bool:
"""Insert an item. Returns False if the queue is full."""
if len(self._entries) >= self.max_size:
return False
item = QueueItem(
weight=weight,
seq=next(self._counter),
recency=recency if recency is not None else time.time(),
request=request,
)
entry = (*item.key(), next(self._counter), item)
heapq.heappush(self._entries, entry)
return True
def pop(self) -> Request | None:
if not self._entries:
return None
return heapq.heappop(self._entries)[-1].request
def peek(self) -> Request | None:
if not self._entries:
return None
return self._entries[0][-1].request
def age(self, dt: float = 1.0) -> None:
"""Pull queued priorities toward critical so old items catch up."""
if self.age_rate <= 0:
return
rebuilt = []
for entry in self._entries:
item = entry[-1]
item.weight += self.age_rate * dt * (1.0 - item.weight)
rebuilt.append((*item.key(), next(self._counter), item))
self._entries = rebuilt
heapq.heapify(self._entries)
def __len__(self) -> int:
return len(self._entries)
def items(self) -> Iterator[tuple[float, Request]]:
for entry in sorted(self._entries, key=lambda e: e[:-1]):
item = entry[-1]
yield item.weight, item.request
+212
View File
@@ -0,0 +1,212 @@
"""The scheduler: gate, choice, score, queue, dispatch.
Every SemIf decision (gate, choice, score, navigation, prediction) is logged.
A high-priority input can preempt the current process, which requeues with its
state preserved; a deferred input is scored and queued by urgency.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .decisions import DecisionRequest, Option, Request
from .engine import SemIfEngine
from .llm import LLMClient
from .log import DecisionLog
from .queue import UrgencyQueue
from .skill import SkillRunner
from .skills import (
ActionContext,
CreateSkill,
Skill,
build_skills,
build_tree,
compose_state,
navigate,
)
GATE_YES = "yes"
CHOICE_INTERRUPT = "interrupt"
URGENCY_OPTIONS = [
("critical", "Immediate danger or critical failure."),
("high", "Important but not dangerous."),
("medium", "Should be handled reasonably soon."),
("low", "Can wait."),
]
URGENCY_WEIGHTS = {"critical": 1.0, "high": 0.75, "medium": 0.5, "low": 0.25}
@dataclass
class Process:
request: Request
skill: str
weight: float
@dataclass
class DispatchResult:
kind: str # ran | create_skill | error
summary: str
skill: str | None = None
decisions_logged: int = 0
class Scheduler:
def __init__(
self,
engine: SemIfEngine,
llm: LLMClient,
log: DecisionLog,
config: dict,
tau: float = 0.6,
max_reentries: int = 3,
):
self.engine = engine
self.llm = llm
self.log = log
self.config = config
self.tau = tau
self.max_reentries = max_reentries
self.queue = UrgencyQueue(
max_size=int(config.get("queue", {}).get("max_size", 100)),
age_rate=float(config.get("queue", {}).get("age_rate", 0.0)),
)
self.skills = build_skills(config)
self.tree = build_tree(self.skills)
self.ctx = ActionContext(engine=self.engine, config=config)
self.runner = SkillRunner(self.ctx, self.llm, self.log)
self.current: Process | None = None
# ---- decision templates (all real SemIf, all logged) ----
def _contains_request(self, request: Request) -> bool:
decision = DecisionRequest(
state=compose_state(request),
question="Does this input contain an actionable request?",
options=[Option(GATE_YES, "Yes, it is actionable."), Option("no", "No, it is not.")],
)
result = self.engine.call(decision)
self.log.append(decision, result, extra={"phase": "gate"})
return result.prob(GATE_YES) >= self.tau
def _choice(self, request: Request, current: Process) -> bool:
decision = DecisionRequest(
state=compose_state(request, current=current.skill),
question="Should this be allowed to interrupt the current process?",
options=[
Option(CHOICE_INTERRUPT, "Yes, interrupt the current process."),
Option("defer", "No, wait until the current process finishes."),
],
)
result = self.engine.call(decision)
self.log.append(decision, result, extra={"phase": "choice", "current": current.skill})
return result.prob(CHOICE_INTERRUPT) >= self.tau
def _score(self, request: Request, current: str | None = None) -> tuple[float, str]:
decision = DecisionRequest(
state=compose_state(request, current=current),
question="How urgent is this request?",
options=[Option(option_id, description) for option_id, description in URGENCY_OPTIONS],
)
result = self.engine.call(decision)
self.log.append(decision, result, extra={"phase": "score"})
label = result.selected
return URGENCY_WEIGHTS[label], label
# ---- intake ----
def submit(self, text: str, source: str = "typed") -> tuple[str, str]:
"""Feed one input. Returns (status, detail)."""
from .engine import EngineUnavailable
try:
return self._submit(text, source)
except EngineUnavailable as exc:
return "error", f"decision engine unavailable: {exc}"
def _submit(self, text: str, source: str = "typed") -> tuple[str, str]:
request = Request(text, source=source)
if not self._contains_request(request):
return "dropped", "no actionable request"
if self.current is None:
weight, label = self._score(request)
self.current = Process(request=request, skill="(scheduling)", weight=weight)
outcome = self._dispatch(request)
self.current = None
return "running", f"[{label}] {outcome.summary}"
interrupt = self._choice(request, self.current)
if interrupt:
previous = self.current
previous.request.resume["from_skill"] = previous.skill
self.queue.push(previous.request, previous.weight)
self.current = Process(request=request, skill="(scheduling)", weight=1.0)
outcome = self._dispatch(request)
self.current = None
return "preempted", f"interrupted {previous.skill}; {outcome.summary}"
weight, label = self._score(request, current=self.current.skill)
ok = self.queue.push(request, weight)
if not ok:
return "rejected", "queue is full"
return "queued", f"urgency {label} (weight {weight:.2f})"
def busy(self, text: str, skill: str = "(driving)") -> None:
"""Set a fake in-progress process so the choice/score path is exercised."""
self.current = Process(request=Request(text, source="busy"), skill=skill, weight=1.0)
def idle(self) -> None:
self.current = None
def run_queue(self) -> list[tuple[str, str]]:
"""Process the queue while idle. Returns the outcomes."""
from .engine import EngineUnavailable
results = []
while self.current is None and len(self.queue) > 0:
request = self.queue.pop()
self.current = Process(request=request, skill="(scheduling)", weight=0.0)
try:
outcome = self._dispatch(request)
except EngineUnavailable as exc:
outcome = DispatchResult(kind="error", summary=f"engine unavailable: {exc}")
self.current = None
results.append(("ran", f"[{request.id}] {outcome.summary}"))
return results
# ---- dispatch ----
def _dispatch(self, request: Request) -> DispatchResult:
navigation = navigate(self.engine, request, self.tree)
if isinstance(navigation, CreateSkill):
return DispatchResult(
kind="create_skill",
summary="skill authoring via opencode is deferred to v2; request logged.",
)
outcome = self.runner.run(navigation, request)
if outcome.error:
return DispatchResult(kind="error", summary=f"skill error: {outcome.error}")
if outcome.updated_request and request.reentries < self.max_reentries:
self.queue.push(_requeue(request, outcome.updated_request), 0.5)
return DispatchResult(
kind="ran",
summary=f"{navigation.name}: {'ok' if outcome.success else 'failed'}{outcome.summary}",
skill=navigation.name,
decisions_logged=outcome.decisions_logged,
)
def status(self) -> str:
lines = []
current = f"{self.current.skill} ({self.current.request.id})" if self.current else "idle"
lines.append(f"current: {current}")
lines.append(f"queue: {len(self.queue)} pending")
for weight, request in self.queue.items():
lines.append(f" {request.id} w={weight:.2f} {request.text[:60]}")
return "\n".join(lines)
def _requeue(request: Request, updated_text: str) -> Request:
updated = Request(updated_text, source="requeue")
updated.reentries = request.reentries + 1
return updated
+65
View File
@@ -0,0 +1,65 @@
"""The skill execution loop: observe -> predict -> act -> observe -> assess.
Every SemIf decision made during a run is logged as a training row; the
assessment outcome is kept on the row so the dream pass can weigh failed runs.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .decisions import Request
from .llm import Assessment, LLMClient
from .log import DecisionLog
from .skills import ActionContext, Prediction, Skill
@dataclass
class RunResult:
skill: str
success: bool
summary: str
action_log: str
new_state: str
updated_request: str | None = None
decisions_logged: int = 0
error: str | None = None
class SkillRunner:
def __init__(self, ctx: ActionContext, llm: LLMClient, log: DecisionLog):
self.ctx = ctx
self.llm = llm
self.log = log
def run(self, skill: Skill, request: Request) -> RunResult:
baseline = request.text
try:
prediction = skill.predict(self.ctx, request) if skill.predict else Prediction(text="")
action = skill.act(self.ctx, request, prediction)
observed = action.new_state
assessment: Assessment = self.llm.assess(skill.name, baseline, action.action_log)
except Exception as exc:
return RunResult(
skill=skill.name,
success=False,
summary="",
action_log="",
new_state=baseline,
error=str(exc),
)
run_ok = assessment.success
decisions = getattr(prediction, "decisions", [])
for decision, result in decisions:
self.log.append(decision, result, extra={"skill": skill.name, "run_ok": run_ok})
return RunResult(
skill=skill.name,
success=assessment.success,
summary=assessment.summary,
action_log=action.action_log,
new_state=observed,
updated_request=assessment.updated_request,
decisions_logged=len(decisions),
)
+192
View File
@@ -0,0 +1,192 @@
"""The skill tree, registry, and SemIf-driven navigation.
A skill is a leaf reached by a chain of SemIf choices (category -> skill).
At every level a "create_skill" branch exists; opencode is the authoring tool
there (deferred to v2, stubbed as CreateSkill).
Only the real skills live here; navigation uses the real decision engine.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from .decisions import DecisionRequest, Option, Request
from .engine import SemIfEngine
@dataclass
class ActionResult:
action_log: str
new_state: str
@dataclass
class Prediction:
"""The predict phase: a forecast plus any SemIf decisions it made."""
text: str
decisions: list[tuple[DecisionRequest, object]] = field(default_factory=list)
@dataclass
class ActionContext:
engine: SemIfEngine
config: dict
@dataclass
class Skill:
name: str
category: str
description: str
cost_budget: float = 1.0
predict: Callable[[ActionContext, Request], Prediction] = field(
default=lambda ctx, req: Prediction(text="")
)
act: Callable[[ActionContext, Request, Prediction], ActionResult] = field(
default=lambda ctx, req, pred: ActionResult("", "")
)
@dataclass
class CreateSkill:
"""Sentinel for the 'create a missing skill' branch at a tree level."""
category: str | None = None
def compose_state(request: Request, current: str | None = None) -> str:
parts = [request.text]
if current:
parts.append(f"[current process: {current}]")
return " ".join(parts)
def _contacts(ctx: ActionContext) -> list[dict]:
path = Path(ctx.config.get("contacts", "data/contacts.json"))
if not path.is_file():
return []
return json.loads(path.read_text())
def _email_predict(ctx: ActionContext, request: Request) -> Prediction:
contacts = _contacts(ctx)
if not contacts:
return Prediction(text="no contacts available", decisions=[])
decision = DecisionRequest(
state=compose_state(request),
question="Which contact is the intended recipient?",
options=[Option(c["name"], c.get("description", "")) for c in contacts]
+ [Option("none", "None of the listed contacts.")],
)
result = ctx.engine.call(decision)
return Prediction(text=f"recipient is {result.selected}", decisions=[(decision, result)])
def _email_compose(ctx: ActionContext, request: Request, prediction: Prediction) -> ActionResult:
recipient = prediction.text.removeprefix("recipient is ")
if recipient == "no contacts available" or recipient == "none":
return ActionResult(
action_log="email.compose aborted: recipient not resolved.",
new_state=request.text,
)
drafts = Path(ctx.config.get("drafts", "data/drafts"))
drafts.mkdir(parents=True, exist_ok=True)
target = drafts / f"{request.id}.txt"
target.write_text(f"To: {recipient}\nBody: {request.text}\n")
return ActionResult(
action_log=f"email.compose: wrote draft {target} for {recipient!r}.",
new_state=f"Draft written to {target.name} for {recipient}.",
)
def _response_reject(ctx: ActionContext, request: Request, prediction: Prediction) -> ActionResult:
message = f"Rejected: I cannot act on this while busy ({request.text})."
return ActionResult(action_log=f"response.reject: {message}", new_state=message)
def _tracking_check(ctx: ActionContext, request: Request, prediction: Prediction) -> ActionResult:
path = Path(ctx.config.get("packages", "data/packages.json"))
if not path.is_file():
return ActionResult(
action_log="tracking.check aborted: no packages file.",
new_state=request.text,
)
packages = json.loads(path.read_text())
lines = [f"{p.get('id')}: {p.get('status')}" for p in packages]
report = "Tracking statuses:\n" + "\n".join(lines)
return ActionResult(action_log="tracking.check: " + report, new_state=report)
def build_skills(config: dict) -> list[Skill]:
skills = config.get("skills", {})
return [
Skill(
name="email.compose",
category="email",
description="Compose and dispatch an email.",
predict=_email_predict,
act=_email_compose,
cost_budget=float(skills.get("email", {}).get("cost_budget", 1.0)),
),
Skill(
name="response.reject",
category="response",
description="Politely reject a request because the agent is busy.",
act=_response_reject,
),
Skill(
name="tracking.check",
category="tracking",
description="Check the delivery status of a package.",
act=_tracking_check,
),
]
def build_tree(skills: list[Skill]) -> dict[str, list[Skill]]:
tree: dict[str, list[Skill]] = {}
for skill in skills:
tree.setdefault(skill.category, []).append(skill)
for category in tree:
tree[category].sort(key=lambda s: s.name)
return tree
def navigate(engine: SemIfEngine, request: Request, tree: dict[str, list[Skill]]) -> Skill | CreateSkill:
"""Descend the tree one SemIf choice per level."""
categories = sorted(tree.keys())
create = Option("create_skill", "Create a new skill for this.")
top = DecisionRequest(
state=compose_state(request),
question="Which top-level category handles this request?",
options=[Option(c, c) for c in categories] + [create],
)
top_result = engine.call(top)
category = top_result.selected
if category == "create_skill":
return CreateSkill(category=None)
skills = tree[category]
leaf = DecisionRequest(
state=compose_state(request, current=category),
question=f"Within {category}, which skill?",
options=[Option(s.name, s.description) for s in skills] + [create],
)
leaf_result = engine.call(leaf)
pick = leaf_result.selected
if pick == "create_skill":
return CreateSkill(category=category)
return next(s for s in skills if s.name == pick)
def tree_summary(tree: dict[str, list[Skill]]) -> str:
lines = []
for category in sorted(tree):
names = ", ".join(s.name for s in tree[category])
lines.append(f" {category}: {names}")
return "\n".join(lines)