Merge feature/orchestrator/s52-night-per-agent into master
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # docs/requirements/001-agent-orchestrator.md # orchestrator/kvb_orchestrator/view.py # orchestrator/templates/page-board.js # tests/test_hub_state.py
This commit is contained in:
commit
c3ab6b7e18
18 files changed
+408
-64
No files matched your search
@@ -135,8 +135,17 @@ def message_item(to: str, cls: str, text: str, upload: str, voice: str, voice_sh
|
||||
return {"kind": "message", "to": to, "class": cls, "text": text, "upload": upload, "voice": voice, "voice_sha256": voice_sha256}
|
||||
|
||||
|
||||
def board_item(action: str) -> dict:
|
||||
return {"kind": "board", "action": action}
|
||||
def board_item(action: str, agents: list[str]) -> dict:
|
||||
"""Args:
|
||||
action: the board action.
|
||||
agents: the session ids a night action is limited to, sorted; empty
|
||||
for every agent, which keeps the item of a board action without
|
||||
agents unchanged.
|
||||
|
||||
Returns:
|
||||
The item the operator's passkey signs.
|
||||
"""
|
||||
return {"kind": "board", "action": action, **({"agents": agents} if agents else {})}
|
||||
|
||||
|
||||
def stream_item(origins: list[str], urls: list[str]) -> dict:
|
||||
|
||||
@@ -17,6 +17,7 @@ NIGHTS = {
|
||||
"suspend": (["suspend"], "Operator ran kvb-org-suspend in the Agent Orchestrator: run kvb-agent-suspend; verify with night.py status."),
|
||||
"resume": (["resume"], "Operator ran kvb-org-resume: run kvb-agent-resume; verify with night.py status."),
|
||||
}
|
||||
ALONE = {action: f"Operator chose {action} for you alone in the Agent Orchestrator: run kvb-agent-{action}; verify with night.py status." for action in NIGHTS}
|
||||
UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
DIRECT = {"post": ["post"], "call": ["send", "call"]}
|
||||
TO_ALL = {"newsletter": ["post"], **{cls: ["send", cls] for cls in ("broadcast", "pan-pan", "security", "mayday")}}
|
||||
@@ -26,6 +27,29 @@ MESSAGE_KEYS = ("to", "text", "upload", "class", "voice")
|
||||
SIGNED_BOARD = ("clear-mayday", *NIGHTS, "estimates")
|
||||
|
||||
|
||||
def night_targets(action: str, payload: dict, claims: list[dict]) -> list[str]:
|
||||
"""Args:
|
||||
action: the board action.
|
||||
payload: the request; its optional "agents" lists the session ids a
|
||||
night action is limited to.
|
||||
claims: the council's claims; only a claimed agent's session can be
|
||||
chosen.
|
||||
|
||||
Returns:
|
||||
The chosen session ids, sorted; empty for every agent.
|
||||
"""
|
||||
agents = payload.pop("agents", [])
|
||||
if not isinstance(agents, list) or not all(isinstance(sid, str) for sid in agents) or len(set(agents)) != len(agents):
|
||||
raise ToolError("agents must be a list of distinct session ids")
|
||||
if agents and action not in NIGHTS:
|
||||
raise ToolError(f"agents apply only to {', '.join(NIGHTS)}")
|
||||
known = {claim["session"] for claim in claims if claim["agent"] != HUMAN and claim["session"] != "-"}
|
||||
unknown = [sid for sid in agents if sid not in known]
|
||||
if unknown:
|
||||
raise ToolError(f"no claimed agent has the session {', '.join(unknown)[:300]}")
|
||||
return sorted(agents)
|
||||
|
||||
|
||||
def chosen(payload: dict, choices: dict[str, list[str]]) -> list[str]:
|
||||
cls = payload["class"]
|
||||
if not isinstance(cls, str) or cls not in choices:
|
||||
@@ -176,16 +200,21 @@ class Messaging:
|
||||
|
||||
def sign_board(self, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("action",))
|
||||
if payload["action"] not in SIGNED_BOARD:
|
||||
action = payload.get("action")
|
||||
if action not in SIGNED_BOARD:
|
||||
raise ToolError(f"action must be one of {', '.join(SIGNED_BOARD)}")
|
||||
return self.challenge_for([signing.board_item(payload["action"])])
|
||||
agents = night_targets(action, payload, read_claims(self.council))
|
||||
expect(payload, ("action",))
|
||||
return self.challenge_for([signing.board_item(action, agents)])
|
||||
|
||||
def board(self, action: str, body: bytes) -> dict:
|
||||
claims = read_claims(self.council)
|
||||
agents = []
|
||||
if action in SIGNED_BOARD:
|
||||
payload = decode(body)
|
||||
agents = night_targets(action, payload, claims)
|
||||
expect(payload, ("proof",))
|
||||
self.operator_proven(payload["proof"], signing.board_item(action))
|
||||
self.operator_proven(payload["proof"], signing.board_item(action, agents))
|
||||
if action == "clear-mayday":
|
||||
runs = [self.scripts.run(RADIO, ["clear", "--force", "--session", HUMAN])]
|
||||
elif action == "broadcast":
|
||||
@@ -212,8 +241,10 @@ class Messaging:
|
||||
runs = [self.scripts.run(ESTIMATE, ["request", "--session", HUMAN])]
|
||||
elif action in NIGHTS:
|
||||
command, text = NIGHTS[action]
|
||||
runs = [self.scripts.run(NIGHT, [*command, "--session", HUMAN])]
|
||||
peers = ordered({claim["address"] for claim in read_claims(self.council) if claim["address"] and claim["agent"] != HUMAN})
|
||||
if agents:
|
||||
text = ALONE[action]
|
||||
runs = [self.scripts.run(NIGHT, [*command, "--session", HUMAN, *(f"--target={sid}" for sid in agents)])]
|
||||
peers = ordered({claim["address"] for claim in claims if claim["address"] and claim["agent"] != HUMAN and (not agents or claim["session"] in agents)})
|
||||
if peers and runs[0]["code"] == 0:
|
||||
targets = [part for peer in peers for part in ("--to", peer)]
|
||||
runs.append(self.scripts.run(RADIO, [*DIRECT["call"], *targets, "--session", HUMAN, text]))
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
const node = state.graph.nodes.find((entry) => entry.id === ui.agent);
|
||||
const tasks = node ? node.tasks : [];
|
||||
const claims = node ? node.claims : [];
|
||||
const part = state.night_parts[ui.agent] || state.night;
|
||||
agentDom.night.replaceChildren(pill(...(NIGHT[part] || [part, "neutral"])));
|
||||
agentDom.info.replaceChildren(
|
||||
...[presenceMark(ui.agent, true)].filter(Boolean),
|
||||
h("p", {class: "muted"}, claims.length ? `Offices: ${claims.map((claim) => claim.office).join(", ")}` : "No office"),
|
||||
@@ -109,10 +111,14 @@
|
||||
const subs = h("div", {}, h("p", {class: "muted"}, "Loading subagents …"));
|
||||
const log = h("div", {"aria-live": "polite"});
|
||||
const life = h("span", {class: "muted"});
|
||||
const night = h("span", {});
|
||||
const claimed = nightAgents().some(([sid]) => sid === key);
|
||||
const nightRow = h("div", {class: "actions agent-night", role: "group", "aria-label": "Day/Night"}, night, h("span", {class: "spacer"}),
|
||||
claimed ? ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small", null, () => [key])) : null);
|
||||
const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name);
|
||||
openPopup(name, [
|
||||
popupHead(face(name, true), name, h("span", {class: "muted"}, former.length ? `Agent · formerly ${former.join(", ")}` : "Agent"), " ", life),
|
||||
h("div", {class: "popup-body"}, info, h("h3", {}, "Subagents"), subs, h("h3", {}, "History"), log),
|
||||
h("div", {class: "popup-body"}, nightRow, info, h("h3", {}, "Subagents"), subs, h("h3", {}, "History"), log),
|
||||
form,
|
||||
], anchor, () => {
|
||||
agentDom = null;
|
||||
@@ -122,7 +128,7 @@
|
||||
}
|
||||
});
|
||||
ui.agent = key;
|
||||
agentDom = {info, history: log};
|
||||
agentDom = {info, history: log, night};
|
||||
drawAgent();
|
||||
loadSubagents(key, subs, life);
|
||||
text.focus();
|
||||
|
||||
@@ -13,14 +13,19 @@
|
||||
|
||||
const actionOf = (action) => ACTIONS.find(([id]) => id === action);
|
||||
|
||||
async function confirmAct(action) {
|
||||
const nightAgents = () => [...new Map(state.claims.filter((claim) => claim.agent && claim.agent !== HUMAN && claim.session !== "-").map((claim) => [claim.session, claim.agent]))]
|
||||
.sort(([one, a], [two, b]) => a.localeCompare(b) || one.localeCompare(two));
|
||||
|
||||
async function confirmAct(action, agents) {
|
||||
const [, label, question] = actionOf(action);
|
||||
if (!window.confirm(question)) {
|
||||
const names = new Map(nightAgents());
|
||||
if (!window.confirm(agents.length ? question.replace("all agents", agents.map((sid) => names.get(sid) || sid).join(", ")) : question)) {
|
||||
return;
|
||||
}
|
||||
const signed = await signedProof("/api/board/sign", {action}, label);
|
||||
const chosen = agents.length ? {agents} : {};
|
||||
const signed = await signedProof("/api/board/sign", {action, ...chosen}, label);
|
||||
if (signed) {
|
||||
await act(action, label, {proof: signed.proof});
|
||||
await act(action, label, {proof: signed.proof, ...chosen});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +36,39 @@
|
||||
|
||||
const picked = (group) => group.querySelector("input:checked").value;
|
||||
|
||||
function actionButton(action, cls, name) {
|
||||
function actionButton(action, cls, name, agents) {
|
||||
const [, label, , hint] = actionOf(action);
|
||||
return h("button", {type: "button", class: cls, ...named(label, hint), on: {click: () => confirmAct(action)}}, name ? icon(name) : null, label);
|
||||
return h("button", {type: "button", class: cls, ...named(label, hint), on: {click: () => confirmAct(action, agents())}}, name ? icon(name) : null, label);
|
||||
}
|
||||
|
||||
function nightChosen() {
|
||||
const agents = nightAgents().map(([sid]) => sid);
|
||||
const on = agents.filter((sid) => !ui.nightOff.has(sid));
|
||||
return on.length === agents.length ? [] : on;
|
||||
}
|
||||
|
||||
function syncPicks(toolbar) {
|
||||
const boxes = [...toolbar.picks.querySelectorAll("input[data-session]")];
|
||||
boxes.forEach((box) => {
|
||||
box.checked = !ui.nightOff.has(box.dataset.session);
|
||||
});
|
||||
toolbar.picks.querySelector("input[data-all]").checked = boxes.every((box) => box.checked);
|
||||
const none = boxes.length > 0 && boxes.every((box) => !box.checked);
|
||||
toolbar.nightButtons.forEach((button) => {
|
||||
button.disabled = none;
|
||||
});
|
||||
}
|
||||
|
||||
function pickAgents(toolbar, sids, on) {
|
||||
sids.forEach((sid) => (on ? ui.nightOff.delete(sid) : ui.nightOff.add(sid)));
|
||||
syncPicks(toolbar);
|
||||
}
|
||||
|
||||
function drawPicks(toolbar) {
|
||||
const agents = nightAgents();
|
||||
const box = (label, attrs, sids) => h("label", {class: "check"}, h("input", {type: "checkbox", ...attrs, on: {change: (event) => pickAgents(toolbar, sids, event.target.checked)}}), label);
|
||||
toolbar.picks.replaceChildren(box("All", {"data-all": true}, agents.map(([sid]) => sid)), ...agents.map(([sid, name]) => box(name, {"data-session": sid}, [sid])));
|
||||
syncPicks(toolbar);
|
||||
}
|
||||
|
||||
let spotDom = null;
|
||||
@@ -63,7 +98,7 @@
|
||||
gear.setAttribute("aria-expanded", "false");
|
||||
gear.setAttribute("aria-controls", "radio-gear");
|
||||
const [, label, , hint] = actionOf("clear-mayday");
|
||||
const clear = iconBtn("bi-life-preserver", label, "btn small danger", () => confirmAct("clear-mayday"), hint);
|
||||
const clear = iconBtn("bi-life-preserver", label, "btn small danger", () => confirmAct("clear-mayday", []), hint);
|
||||
const panel = h("section", {class: "card radio-spot", id: "radio-spot", "aria-label": "Radio", hidden: !ui.spot},
|
||||
h("div", {class: "spot-head"}, h("h3", {class: "group-title"}, "Radio"), h("span", {class: "spacer"}), gear, clear), h("div", {class: "spot-body"}, form, radioSide(), gearBox));
|
||||
const toggle = document.getElementById("radio-open");
|
||||
@@ -89,10 +124,12 @@
|
||||
|
||||
function agentsToolbar() {
|
||||
const mode = h("span", {});
|
||||
const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
|
||||
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small")))));
|
||||
const picks = h("div", {class: "night-picks", role: "group", "aria-label": "Agents"});
|
||||
const nightButtons = ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small", null, nightChosen));
|
||||
const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode, picks,
|
||||
h("div", {class: "actions"}, nightButtons)));
|
||||
const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night, statusMenu());
|
||||
return {bar, mode};
|
||||
return {bar, mode, picks, nightButtons, agents: ""};
|
||||
}
|
||||
|
||||
function lookBox(id, label, on, run) {
|
||||
@@ -124,5 +161,11 @@
|
||||
return;
|
||||
}
|
||||
const [mode, tone] = NIGHT[state.night] || [state.night, "neutral"];
|
||||
toolbar.mode.replaceChildren(pill(mode, tone));
|
||||
const alone = Object.values(state.night_parts).filter((part) => part !== "day").length;
|
||||
toolbar.mode.replaceChildren(pill(mode, tone), alone ? pill(`${alone} alone`, "warn") : null);
|
||||
const agents = JSON.stringify(nightAgents());
|
||||
if (agents !== toolbar.agents) {
|
||||
toolbar.agents = agents;
|
||||
drawPicks(toolbar);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "saved view", run: () => useSaved(view)})),
|
||||
...agentNames().map((name) => ({label: name, hint: "Agent", face: name, run: () => openAgent(name)})),
|
||||
{label: "Radio …", hint: "Post, Call, Newsletter, Broadcast, Pan-Pan, Security, Mayday; the gear holds tones and read aloud", run: openRadio},
|
||||
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id)})),
|
||||
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id, [])})),
|
||||
{label: "Priority …", hint: "agent order in the hierarchy", run: openAgentOrder},
|
||||
{label: "Firewall …", hint: "automatic grants", run: () => go("rules", null)},
|
||||
{label: "Settings …", hint: "Ambience, location, colors, music, notifications, internet radio", run: settingsSheet},
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
const banner = document.getElementById("mayday");
|
||||
const silence = state.silence;
|
||||
banner.hidden = !silence;
|
||||
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "cancel", "btn small", () => confirmAct("clear-mayday"), "cancel: end the mayday (radio.py clear)")] : []));
|
||||
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "cancel", "btn small", () => confirmAct("clear-mayday", []), "cancel: end the mayday (radio.py clear)")] : []));
|
||||
document.title = `${state.badges.inbox ? `(${state.badges.inbox}) ` : ""}Agent Orchestrator`;
|
||||
window.setTimeout(measure, 0);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
let state = JSON.parse(document.getElementById("state").textContent);
|
||||
const KINDS = Object.entries(state.kinds);
|
||||
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", spot: false, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
||||
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", spot: false, nightOff: new Set(), cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
||||
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
|
||||
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
|
||||
const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null};
|
||||
|
||||
@@ -61,6 +61,9 @@ body.fit .spot-host { display: flex; flex: 0 1 auto; flex-direction: column; min
|
||||
body.fit .radio-spot { flex: 0 1 auto; min-height: 0; }
|
||||
.radio-side { display: grid; gap: var(--sp-3); align-content: start; min-width: 0; }
|
||||
.spot-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
|
||||
.night-picks { display: flex; flex-wrap: wrap; gap: 4px var(--sp-3); max-width: 420px; max-height: 30vh; overflow: auto; }
|
||||
.night-picks:empty { display: none; }
|
||||
.agent-night { align-items: center; margin: 0 0 var(--sp-2); }
|
||||
.spot-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap: var(--sp-4); align-items: start; }
|
||||
.radio-gear { min-width: 0; }
|
||||
.composer { display: grid; gap: var(--sp-2); }
|
||||
|
||||
Reference in new issue
Block a user