The Agents views gain a Hierarchie view: an org chart of the chair and the line offices read from the council claims. The operator appoints, hands over and recalls an office there; the page signs each change with the passkey and a host unit (client/offices.py, offices path unit) verifies the signature, removes a live holder itself and runs a plain council.py claim for the successor. council.py loses the --operator flag, so no agent can override a live holder. The agent order is now separate from task priority: rasci.py agents order stores it in agent-order.json beside the register through the nofollow helpers and never writes a --prio pin. It only breaks ties of equal task rank, also among the queue grants. The Hierarchie view edits it as a drag list. Host units run the installed skill scripts, so orchestrator.sh keeps the offices unit off while the installed council.py, nofollow.py or rasci.py differ from the tree. Job files, council files and hub records are read with O_NOFOLLOW and O_NONBLOCK and size caps; nofollow.rewrite replaces a claim without creating directories, so a beat never revives a released office. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: 79 % 🛡️ Security: 100 % 🏆 Score: 100 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
10 KiB
Python
189 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from .council import NEWS, ordered, read_claims
|
|
from .errors import ToolError
|
|
from .events import VIEWS
|
|
from .fields import bounded, decode, expect, message_text
|
|
from .records import read_jsonl
|
|
from .register import REGISTER, agent_order, read_register
|
|
|
|
NL_LIMIT = 2000
|
|
NL_ACTION_LIMIT = 20
|
|
LLM_TIMEOUT = 60
|
|
LLM_REPLY = 1 << 20
|
|
|
|
|
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
return None
|
|
|
|
|
|
LLM_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect)
|
|
SCREENS = ("stream", "inbox", "rules", "agents", "artifacts")
|
|
IDS = {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "Ids from items."}
|
|
NL_ACTIONS = [
|
|
{"name": "approve", "description": "Approve inbox items; a decision takes its recommended option.", "parameters": {"ids": IDS}},
|
|
{"name": "reject", "description": "Reject reviews or deny asks.", "parameters": {"ids": IDS}},
|
|
{"name": "choose", "description": "Pick an option label of a decision.", "parameters": {"id": {"type": "string"}, "label": {"type": "string"}}},
|
|
{"name": "snooze", "description": "Hide inbox items for some hours.", "parameters": {"ids": IDS, "hours": {"type": "number", "minimum": 1, "maximum": 192}}},
|
|
{"name": "pin", "description": "Pin inbox items to the top.", "parameters": {"ids": IDS}},
|
|
{"name": "view", "description": "Show a saved view of the stream.", "parameters": {"name": {"type": "string", "enum": list(VIEWS)}}},
|
|
{"name": "open", "description": "Switch the screen.", "parameters": {"view": {"type": "string", "enum": list(SCREENS)}}},
|
|
{"name": "message", "description": "Radio a direct message to an agent.", "parameters": {"to": {"type": "string"}, "text": {"type": "string"}}},
|
|
{"name": "filter", "description": "Search the stream for a text.", "parameters": {"q": {"type": "string"}}},
|
|
]
|
|
|
|
|
|
def nl_messages(text: str, items: list[dict], agents: list[str]) -> list[dict]:
|
|
schemas = [{"name": action["name"], "description": action["description"], "arguments": action["parameters"]} for action in NL_ACTIONS]
|
|
system = (
|
|
"You turn one command of the Agent Orchestrator operator into hub actions. Reply with exactly one JSON object "
|
|
'{"actions": [{"name": "<action>", "arguments": {...}}]} and nothing else. Every action has one of these '
|
|
f"names, and its arguments object holds exactly the listed properties: {json.dumps(schemas, ensure_ascii=False)}. "
|
|
'Use only ids from items and names from agents. Reply {"actions": []} when nothing fits.'
|
|
)
|
|
user = json.dumps({"command": text, "items": items, "agents": agents}, ensure_ascii=False)
|
|
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
|
|
|
|
|
def nl_ids(arguments: dict, items: dict[str, dict]) -> list[dict]:
|
|
ids = arguments["ids"]
|
|
if not isinstance(ids, list) or not ids or not all(isinstance(ident, str) and ident in items for ident in ids):
|
|
raise ToolError("ids must name inbox items")
|
|
if len(set(ids)) != len(ids):
|
|
raise ToolError("ids must be distinct")
|
|
return [items[ident] for ident in ids]
|
|
|
|
|
|
def nl_item(item: dict, verb: str, label: str) -> dict:
|
|
return {"kind": item["kind"], "id": item["id"], "verb": verb, "label": label, "sha256": item["sha256"]}
|
|
|
|
|
|
def nl_action(raw: object, items: dict[str, dict], agents: list[str]) -> dict:
|
|
if not isinstance(raw, dict) or set(raw) != {"name", "arguments"} or not isinstance(raw["arguments"], dict):
|
|
raise ToolError("an action must be an object with name and arguments")
|
|
schema = next((action for action in NL_ACTIONS if action["name"] == raw["name"]), None)
|
|
if schema is None:
|
|
raise ToolError(f"unknown action {str(raw['name'])[:40]!r}")
|
|
name, arguments = schema["name"], raw["arguments"]
|
|
expect(arguments, tuple(schema["parameters"]))
|
|
if name in ("approve", "reject"):
|
|
picked = nl_ids(arguments, items)
|
|
if name == "reject" and any(item["kind"] not in ("review", "ask") for item in picked):
|
|
raise ToolError("reject takes reviews and asks only")
|
|
if name == "approve" and any(item["kind"] == "decision" and not item["recommended"] for item in picked):
|
|
raise ToolError("a decision without a recommended option needs choose")
|
|
if name == "approve" and any(item["kind"] == "operator" and not item["runnable"] for item in picked):
|
|
raise ToolError("that operator row holds no command to run")
|
|
chosen = [nl_item(item, "approve" if name == "approve" else "reject", item["recommended"] if item["kind"] == "decision" else "") for item in picked]
|
|
lines = [f"{item['kind']} {item['id']}: {item['command'] if item['kind'] == 'operator' else item['title']}" for item in picked]
|
|
verb = "Freigeben" if name == "approve" else "Ablehnen"
|
|
return {"name": name, "summary": f"{verb}:\n" + "\n".join(lines), "args": {"items": chosen}}
|
|
if name == "choose":
|
|
item = items.get(arguments["id"]) if isinstance(arguments["id"], str) else None
|
|
if item is None or item["kind"] != "decision" or arguments["label"] not in [option["label"] for option in item["options"]]:
|
|
raise ToolError("choose takes a decision id and one of its option labels")
|
|
return {"name": name, "summary": f"Entscheiden: {item['title']} → {arguments['label']}", "args": {"items": [nl_item(item, "approve", arguments["label"])]}}
|
|
if name in ("snooze", "pin"):
|
|
picked = nl_ids(arguments, items)
|
|
hours = arguments.get("hours")
|
|
if name == "snooze" and (isinstance(hours, bool) or not isinstance(hours, int | float) or not 1 <= hours <= 192):
|
|
raise ToolError("hours must be between 1 and 192")
|
|
keys = [{"kind": item["kind"], "id": item["id"]} for item in picked]
|
|
words = f"Zurückstellen ({hours} h)" if name == "snooze" else "Anheften"
|
|
return {"name": name, "summary": f"{words}: " + ", ".join(item["title"][:80] for item in picked), "args": {"keys": keys, "hours": hours}}
|
|
if name == "view":
|
|
if arguments["name"] not in VIEWS:
|
|
raise ToolError("unknown view")
|
|
return {"name": name, "summary": f"Ansicht: {VIEWS[arguments['name']]['label']}", "args": {"name": arguments["name"]}}
|
|
if name == "open":
|
|
if arguments["view"] not in SCREENS:
|
|
raise ToolError("unknown screen")
|
|
return {"name": name, "summary": f"Öffnen: {arguments['view']}", "args": {"view": arguments["view"]}}
|
|
if name == "message":
|
|
text = message_text(arguments, "text")
|
|
if arguments["to"] not in agents:
|
|
raise ToolError("to must name a known agent")
|
|
return {"name": name, "summary": f"Funk an {arguments['to']}: {text}", "args": {"to": arguments["to"], "text": text}}
|
|
query = bounded(arguments, "q", 200)
|
|
return {"name": name, "summary": f"Suchen: {query}", "args": {"q": query}}
|
|
|
|
|
|
def nl_actions(reply: str, items: list[dict], agents: list[str]) -> dict:
|
|
text = reply.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
|
try:
|
|
data = json.loads(text)
|
|
except (ValueError, RecursionError):
|
|
raise ToolError("the model answered no JSON object") from None
|
|
if not isinstance(data, dict) or not isinstance(data.get("actions"), list):
|
|
raise ToolError("the model answered no actions list")
|
|
known = {item["id"]: item for item in items}
|
|
accepted, rejected = [], []
|
|
for raw in data["actions"][:NL_ACTION_LIMIT]:
|
|
try:
|
|
accepted.append(nl_action(raw, known, agents))
|
|
except ToolError as error:
|
|
rejected.append({"action": json.dumps(raw, ensure_ascii=False)[:300], "reason": str(error)})
|
|
return {"actions": accepted, "rejected": rejected}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Model:
|
|
url: str
|
|
model: str
|
|
key: str
|
|
|
|
def enabled(self) -> bool:
|
|
return bool(self.url and self.model)
|
|
|
|
def complete(self, messages: list[dict]) -> str:
|
|
target = self.url.rstrip("/")
|
|
target = target if target.endswith("/chat/completions") else f"{target}/chat/completions"
|
|
headers = {"Content-Type": "application/json"}
|
|
if self.key:
|
|
headers["Authorization"] = f"Bearer {self.key}"
|
|
body = json.dumps({"model": self.model, "messages": messages, "temperature": 0, "response_format": {"type": "json_object"}}).encode()
|
|
request = urllib.request.Request(target, data=body, headers=headers, method="POST")
|
|
try:
|
|
with LLM_OPENER.open(request, timeout=LLM_TIMEOUT) as response:
|
|
data = json.loads(response.read(LLM_REPLY))
|
|
content = data["choices"][0]["message"]["content"]
|
|
except (OSError, ValueError, KeyError, IndexError, TypeError) as error:
|
|
raise ToolError(f"the model endpoint failed: {error}") from None
|
|
if not isinstance(content, str):
|
|
raise ToolError("the model endpoint answered no text")
|
|
return content
|
|
|
|
|
|
def read_key(path: Path) -> str:
|
|
try:
|
|
return path.read_text(encoding="utf-8").strip()
|
|
except (OSError, ValueError):
|
|
return ""
|
|
|
|
|
|
class Language:
|
|
def nl(self, body: bytes) -> dict:
|
|
if not self.model.enabled():
|
|
raise ToolError("the natural-language command line is off: set KVB_ORCHESTRATOR_LLM_URL and KVB_ORCHESTRATOR_LLM_MODEL in orchestrator/.env")
|
|
payload = decode(body)
|
|
expect(payload, ("text",))
|
|
text = message_text(payload, "text")
|
|
if len(text) > NL_LIMIT:
|
|
raise ToolError(f"text must be at most {NL_LIMIT} characters")
|
|
now = time.time()
|
|
claims = read_claims(self.council)
|
|
tasks = read_register(self.council / REGISTER)
|
|
directory = self.directory(claims, read_jsonl(self.council / NEWS), now)
|
|
self.watch.see(tasks, now)
|
|
items = self.inbox(now, tasks, directory)
|
|
agents = ordered({claim["agent"] for claim in claims if claim["agent"]} | set(agent_order(tasks, [], [])))
|
|
shown_items = [{"id": item["id"], "kind": item["kind"], "title": item["title"][:200]} for item in items]
|
|
return nl_actions(self.model.complete(nl_messages(text, shown_items, agents)), items, agents)
|