feat(orchestrator): S5.2 drain, suspend and resume chosen agents
The Day/Night menu lists every claimed agent with a checkbox and an All box; Drain, Suspend and Resume act on the ticked agents only, are disabled when none is ticked, and the agent popup carries the same three buttons for that one agent. Each action is passkey-signed with its agent list, night.py takes --target per session and keeps a per-agent part, and each chosen agent gets a call naming the action and its skill. The night skills say how to read a per-agent night. 🎯 Fix: 90 % 💥 Break: 5 % 💸 Cost: 106 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 81 🚦 Gate: ⚠️ cost 106 % > 100: operator approved the commit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57ab29eb0d
commit
995c7cb214
21 files changed
+430
-65
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:
|
||||
|
||||
@@ -169,6 +169,25 @@ def night_mode(council: Path) -> str:
|
||||
return mode if mode in MODES else "day"
|
||||
|
||||
|
||||
def night_parts(council: Path) -> dict[str, str]:
|
||||
"""Args:
|
||||
council: the council directory with night.json.
|
||||
|
||||
Returns:
|
||||
Per session the part that differs from night_mode: drain or suspend
|
||||
for one set alone, day for the active and the resumed sessions while
|
||||
a mode holds.
|
||||
"""
|
||||
data = read_json(council / "night.json")
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
resumed = data.get("resumed", data.get("awake"))
|
||||
working = [data.get("active"), *(resumed if isinstance(resumed, list) else [])]
|
||||
parts = {sid: "day" for sid in working if isinstance(sid, str)} if night_mode(council) != "day" else {}
|
||||
alone = data.get("parts")
|
||||
return {**parts, **({sid: mode for sid, mode in alone.items() if mode in MODES} if isinstance(alone, dict) else {})}
|
||||
|
||||
|
||||
def silence_of(council: Path, now: float) -> dict | None:
|
||||
data = read_json(council / "radio-silence.json")
|
||||
if not isinstance(data, 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]))
|
||||
|
||||
@@ -8,6 +8,7 @@ from .council import (
|
||||
NEWS,
|
||||
grants,
|
||||
night_mode,
|
||||
night_parts,
|
||||
radio_rows,
|
||||
read_claims,
|
||||
sessions,
|
||||
@@ -138,6 +139,7 @@ class View:
|
||||
"claims": claims,
|
||||
"grants": grant_rows,
|
||||
"night": night_mode(self.council),
|
||||
"night_parts": night_parts(self.council),
|
||||
"silence": silence_of(self.council, now),
|
||||
"graph": drawn,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
h("p", {class: "muted"}, claims.length ? `Offices: ${claims.map((claim) => claim.office).join(", ")}` : "No office"),
|
||||
h("h3", {}, "Tasks", counter(tasks.length)),
|
||||
@@ -108,10 +110,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;
|
||||
@@ -121,7 +127,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);
|
||||
}
|
||||
|
||||
function openSpot(spot, open) {
|
||||
@@ -58,7 +93,7 @@
|
||||
const {form, recipient, text} = composerForm();
|
||||
const gearBox = h("div", {class: "radio-gear", id: "radio-gear", hidden: true});
|
||||
const gear = h("button", {type: "button", class: "btn small ghost icon-only spot-toggle", "aria-expanded": "false", "aria-controls": "radio-gear", ...named("Radio settings", "Radio settings: tones, squelch and read aloud")}, icon("bi-gear"));
|
||||
const clear = actionButton("clear-mayday", "btn small danger", "bi-life-preserver");
|
||||
const clear = actionButton("clear-mayday", "btn small danger", "bi-life-preserver", () => []);
|
||||
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, gearBox));
|
||||
const toggle = h("button", {type: "button", class: "btn small spot-toggle", "aria-expanded": String(ui.spot), "aria-controls": "radio-spot", ...named("Radio", "Radio: post, call or a message to all, with Record and the radio settings")}, icon("bi-broadcast-pin"), caret());
|
||||
@@ -82,10 +117,12 @@
|
||||
function agentsToolbar() {
|
||||
const spot = radioSpot();
|
||||
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"}, spot.toggle, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night);
|
||||
return {bar, spot: spot.panel, clear: spot.clear, recipient: spot.recipient, mode, names: ""};
|
||||
return {bar, spot: spot.panel, clear: spot.clear, recipient: spot.recipient, mode, picks, nightButtons, names: "", agents: ""};
|
||||
}
|
||||
|
||||
function drawToolbar() {
|
||||
@@ -94,8 +131,14 @@
|
||||
return;
|
||||
}
|
||||
const [mode, tone] = NIGHT[state.night] || [state.night, "neutral"];
|
||||
const alone = Object.values(state.night_parts).filter((part) => part !== "day").length;
|
||||
toolbar.clear.disabled = !state.silence;
|
||||
toolbar.mode.replaceChildren(pill(mode, tone));
|
||||
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);
|
||||
}
|
||||
const names = agentNames();
|
||||
if (JSON.stringify(names) !== toolbar.names) {
|
||||
toolbar.names = JSON.stringify(names);
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -97,7 +97,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);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,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};
|
||||
|
||||
@@ -42,6 +42,9 @@ svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speak
|
||||
.radio-spot[hidden], .radio-gear[hidden] { display: none; }
|
||||
body.fit .radio-spot { flex: 0 1 auto; min-height: 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