Ship War Room UX, CEO inbox, mobile chat, and Agents NAS workspace.

Make A2A togglable, keep reply context while reading, route agent data to //10.4.7.11/share/Agents, and polish themes plus export-intel sync cancel.
This commit is contained in:
Aissa
2026-08-16 22:40:32 +00:00
parent 910ba465d7
commit c5ea8cbb63
26 changed files with 2891 additions and 407 deletions
+2 -2
View File
@@ -21,9 +21,9 @@ AGENT_STATUSES = [
@router.get("")
async def agents_page(request: Request):
active_tab = request.query_params.get("tab", "souls")
active_tab = request.query_params.get("tab", "warroom")
if active_tab not in {"souls", "mesh", "terminals", "approvals", "warroom"}:
active_tab = "souls"
active_tab = "warroom"
events: list = []
try:
events = fetch_all(
+47 -1
View File
@@ -551,6 +551,7 @@ def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str
class WarroomMessageBody(BaseModel):
content: str = Field("", max_length=8000)
attachments: list[dict] = Field(default_factory=list)
reply_to_id: Optional[int] = None
@router.get("/warroom/rooms")
@@ -575,9 +576,12 @@ async def warroom_list_messages(room_id: int, limit: int = 100, after_id: int |
from app.services import agent_chat
agent_chat.ensure_warroom()
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
items = agent_chat.list_messages(room_id, limit=min(limit, 300), after_id=after_id)
deleted_ids = agent_chat.list_deleted_ids(room_id) if after_id else []
return {"ok": True, "items": items, "deleted_ids": deleted_ids}
return {"ok": True, "room": room, "items": items, "deleted_ids": deleted_ids}
@router.post("/warroom/rooms/{room_id}/messages")
@@ -592,6 +596,7 @@ async def warroom_post_message(request: Request, room_id: int, body: WarroomMess
role=(user.get("role") if isinstance(user, dict) else None),
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
attachments=body.attachments,
reply_to_id=body.reply_to_id,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@@ -615,6 +620,7 @@ async def warroom_post_message_stream(request: Request, room_id: int, body: Warr
role=(user.get("role") if isinstance(user, dict) else None),
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
attachments=body.attachments,
reply_to_id=body.reply_to_id,
):
yield f"data: {json.dumps(ev, default=str)}\n\n"
except ValueError as exc:
@@ -715,6 +721,46 @@ async def warroom_continue(request: Request, room_id: int, turns: int = 2):
@router.get("/warroom/ceo-inbox")
async def warroom_ceo_inbox(unread: bool = False, limit: int = 50) -> dict[str, Any]:
"""Direct messages from agents to CEO (@aissa)."""
from app.services import agent_chat
items = agent_chat.list_ceo_inbox(unread_only=unread, limit=min(limit, 100))
return {
"ok": True,
"items": items,
"unread": agent_chat.ceo_inbox_unread_count(),
}
class CeoInboxReadBody(BaseModel):
ids: Optional[list[int]] = None
all: bool = False
@router.post("/warroom/ceo-inbox/read")
async def warroom_ceo_inbox_read(body: CeoInboxReadBody) -> dict[str, Any]:
from app.services import agent_chat
n = agent_chat.mark_ceo_inbox_read(body.ids, all_unread=bool(body.all))
return {"ok": True, "marked": n, "unread": agent_chat.ceo_inbox_unread_count()}
@router.delete("/warroom/rooms/{room_id}/messages")
async def warroom_clear_messages(room_id: int) -> dict[str, Any]:
"""Soft-delete all messages in a War Room / DM."""
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
n = agent_chat.clear_messages(room_id)
return {"ok": True, "cleared": n}
@router.delete("/warroom/rooms/{room_id}/messages/{message_id}")
async def warroom_delete_message(room_id: int, message_id: int) -> dict[str, Any]:
"""Soft-delete a War Room message."""
+5
View File
@@ -305,6 +305,11 @@ async def api_sync_places(request: Request):
return r.json()
@router.post("/api/export-intel/sync/cancel")
async def api_sync_cancel():
return await _proxy("POST", "/sync/cancel")
@router.get("/api/export-intel/sync/status")
async def api_sync_status():
return await _proxy("GET", "/sync/status")
+353 -53
View File
@@ -9,7 +9,7 @@ from typing import Any
from app.config import settings
from app.db import execute, fetch_all, fetch_one
from app.services import agent_souls, hermes_memory, llm_router
from app.services import agent_nas, agent_souls, hermes_memory, llm_router
from app.services.agent_names import normalize_agent_key
log = logging.getLogger("cockpit.agent_chat")
@@ -20,7 +20,7 @@ MAX_REPLIES_PER_ROUND = 3
HISTORY_LIMIT = 16
WARROOM_MAX_TOKENS = 512
WARROOM_TIMEOUT = 55.0
A2A_CONTINUE_MAX = 3
A2A_CONTINUE_MAX = 1
COCKPIT_SNAPSHOT_TTL = 60.0
@@ -132,6 +132,14 @@ def _iso(row: dict[str, Any] | None) -> dict[str, Any] | None:
return out
def _bootstrap_agents_nas() -> None:
try:
keys = [str(s.get("agent_key")) for s in agent_souls.list_souls()]
agent_nas.bootstrap_all(keys)
except Exception:
log.exception("agents NAS bootstrap failed")
def ensure_warroom() -> dict[str, Any]:
row = fetch_one("SELECT * FROM warroom_rooms WHERE kind = 'warroom' LIMIT 1")
if row:
@@ -173,6 +181,18 @@ def list_rooms() -> list[dict[str, Any]]:
return [_iso(r) for r in rows] # type: ignore
def get_message(message_id: int, room_id: int | None = None) -> dict[str, Any] | None:
if room_id is not None:
return _iso(fetch_one(
"SELECT * FROM warroom_messages WHERE id = %s AND room_id = %s AND deleted_at IS NULL",
(message_id, room_id),
))
return _iso(fetch_one(
"SELECT * FROM warroom_messages WHERE id = %s AND deleted_at IS NULL",
(message_id,),
))
def get_room(room_id: int) -> dict[str, Any] | None:
return _iso(fetch_one("SELECT * FROM warroom_rooms WHERE id = %s", (room_id,)))
@@ -213,6 +233,21 @@ def delete_message(room_id: int, message_id: int) -> dict[str, Any] | None:
return _iso(row) if row else None # type: ignore
def clear_messages(room_id: int) -> int:
"""Soft-delete all messages in a room. Returns count cleared."""
rows = fetch_all(
"""UPDATE warroom_messages
SET deleted_at = NOW(),
content = '',
metadata = COALESCE(metadata, '{}'::jsonb) || '{"deleted": true, "cleared": true}'::jsonb
WHERE room_id = %s AND deleted_at IS NULL
RETURNING id""",
(room_id,),
)
execute("UPDATE warroom_rooms SET updated_at = NOW() WHERE id = %s", (room_id,))
return len(rows or [])
def list_deleted_ids(room_id: int) -> list[int]:
"""Recently soft-deleted message ids (for live UI sync across clients)."""
rows = fetch_all(
@@ -260,6 +295,121 @@ def _active_agent_keys() -> set[str]:
return {str(s.get("agent_key")) for s in agent_souls.list_souls() if s.get("is_active", True)}
CEO_ALIASES = {"aissa", "ceo", "eigenaar", "ervoorz"} # ervoorz = legacy alias
CEO_MENTION_RE = re.compile(r"@([A-Za-z0-9_\-]{2,40})", re.I)
def parse_ceo_mentions(text: str) -> bool:
"""True if message is directed at CEO via @aissa (legacy: @ervoorz / @ceo)."""
for m in CEO_MENTION_RE.findall(text or ""):
if m.lower() in CEO_ALIASES:
return True
return False
def _ceo_excerpt(text: str, limit: int = 280) -> str:
t = (text or "").strip()
if len(t) <= limit:
return t
return t[: limit - 1] + "…"
def record_ceo_direct(message_row: dict[str, Any] | None) -> dict[str, Any] | None:
"""If an agent message @mentions CEO, store in ceo inbox."""
if not message_row or message_row.get("sender_type") != "agent":
return None
content = message_row.get("content") or ""
if not parse_ceo_mentions(content):
return None
ensure_warroom()
# ensure table exists (idempotent)
try:
execute(
"""CREATE TABLE IF NOT EXISTS warroom_ceo_inbox (
id BIGSERIAL PRIMARY KEY,
message_id BIGINT,
room_id BIGINT,
agent_key VARCHAR(64),
content TEXT NOT NULL,
excerpt TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
read_at TIMESTAMPTZ,
recipient TEXT NOT NULL DEFAULT 'aissa'
)"""
)
except Exception:
pass
row = fetch_one(
"""INSERT INTO warroom_ceo_inbox (message_id, room_id, agent_key, content, excerpt, recipient)
VALUES (%s, %s, %s, %s, %s, 'aissa')
RETURNING *""",
(
message_row.get("id"),
message_row.get("room_id"),
message_row.get("agent_key"),
content,
_ceo_excerpt(content),
),
)
return _iso(row) if row else None
def list_ceo_inbox(*, unread_only: bool = False, limit: int = 50) -> list[dict[str, Any]]:
ensure_warroom()
try:
if unread_only:
rows = fetch_all(
"""SELECT i.*, s.display_name AS agent_display_name
FROM warroom_ceo_inbox i
LEFT JOIN agent_souls s ON s.agent_key = i.agent_key
WHERE i.recipient = 'aissa' AND i.read_at IS NULL
ORDER BY i.created_at DESC LIMIT %s""",
(limit,),
)
else:
rows = fetch_all(
"""SELECT i.*, s.display_name AS agent_display_name
FROM warroom_ceo_inbox i
LEFT JOIN agent_souls s ON s.agent_key = i.agent_key
WHERE i.recipient = 'aissa'
ORDER BY i.created_at DESC LIMIT %s""",
(limit,),
)
except Exception:
return []
return [_iso(r) for r in (rows or [])] # type: ignore
def ceo_inbox_unread_count() -> int:
try:
row = fetch_one(
"""SELECT COUNT(*) AS n FROM warroom_ceo_inbox
WHERE recipient = 'aissa' AND read_at IS NULL"""
)
return int((row or {}).get("n") or 0)
except Exception:
return 0
def mark_ceo_inbox_read(ids: list[int] | None = None, *, all_unread: bool = False) -> int:
if all_unread:
rows = fetch_all(
"""UPDATE warroom_ceo_inbox SET read_at = NOW()
WHERE recipient = 'aissa' AND read_at IS NULL
RETURNING id"""
)
return len(rows or [])
if not ids:
return 0
rows = fetch_all(
"""UPDATE warroom_ceo_inbox SET read_at = NOW()
WHERE recipient = 'aissa' AND id = ANY(%s) AND read_at IS NULL
RETURNING id""",
(ids,),
)
return len(rows or [])
def parse_mentions(text: str) -> list[str]:
keys = _active_agent_keys()
found: list[str] = []
@@ -327,7 +477,7 @@ def _soul_system(soul: dict[str, Any], *, room_kind: str) -> str:
name = soul.get("display_name") or soul.get("agent_key")
key = soul.get("agent_key")
role = soul.get("role_title") or "agent"
soul_md = (soul.get("soul_md") or "")[:700]
soul_md = (soul.get("soul_md") or "")[:1600]
resp = (soul.get("responsibilities") or "")[:350]
roster = _live_roster_line()
hard = (
@@ -353,22 +503,35 @@ def _soul_system(soul: dict[str, Any], *, room_kind: str) -> str:
"- Jij bent orchestrator: bij feitelijke retail/CRM-vragen eerst @retail of @bizdev laten antwoorden, "
"of alleen samenvatten wat zij/data al zeiden. Liever geen antwoord dan een verkeerd antwoord.\n"
)
extra = (
accuracy
+ "War Room-regels:\n"
"- Max 4 korte zinnen, Nederlands.\n"
"- Geen @zelf-prefix / geen herhalingen.\n"
"- Peer @mentions oké; zeg nooit dat agents uit staan.\n"
f"{snap}\n"
if room_kind == "warroom"
else accuracy + "1:1 DM: bondig NL. Alleen zekere cockpit-feiten.\n"
)
if room_kind == "warroom":
extra = (
accuracy
+ "War Room-regels:\n"
+ "- Max 4 korte zinnen, Nederlands.\n"
+ "- Geen @zelf-prefix / geen herhalingen.\n"
+ "- Peer @mentions verplicht: eindig met @iemand + gerichte vraag of reactie.\n"
+ "- Zeg nooit dat agents uit staan.\n"
+ "- Menselijk & echt: reageer concreet op de vorige spreker (feit, mop of vraag).\n"
+ "- Lichte food-moppen / Foodlinkk-humor mag (respectvol, geen geloof/politiek/grof).\n"
+ "- Mix: meestal inhoud, af en toe 1 mop — daarna weer professioneel.\n"
+ "- Na een mop van een collega: lach kort terug of bouw erop voort, en @mention door.\n"
+ "- CEO heet Aissa. Spreek hem normaal aan met 'Aissa' (of je/jij).\n"
+ "- Volg Aissa's laatste input als prioriteit. Bouw daarop voort, niet ernaast.\n"
+ "- Escalatie naar CEO-inbox: gebruik @aissa + concrete vraag/actie (alleen als echt nodig).\n"
+ "- Goed: 'Aissa, hier is de status…' of '@aissa we hebben jouw go nodig voor X'.\n"
+ "- Gebruik NOOIT @ervoorz (verouderd/verwarrend).\n"
+ "- Nodig collega's uit: @mention wie iets kan toevoegen (finance/bizdev/retail/…).\n"
+ f"{snap}\n"
)
else:
extra = accuracy + "1:1 DM: bondig NL. Alleen zekere cockpit-feiten.\n"
return (
f"{hard}\n"
f"Je bent {name} (@{key}), {role} bij Foodlinkk.\n"
f"Spreek ALLEEN als {name} — niet als een andere agent.\n"
f"Focus: {resp or '—'}\n"
f"{soul_md}\n\n{extra}"
f"{soul_md}\n\n{extra}\n\n{agent_nas.memory_rule_text()}"f"- Jouw NAS-werkmap: {agent_nas.agent_paths(str(key))['smb']}\n"
)
@@ -426,6 +589,53 @@ _ALIAS_MAP = {
}
def _topic_contributors(
text: str,
*,
exclude: set[str] | None = None,
limit: int = 2,
) -> list[str]:
"""Pick agents who can meaningfully add to the topic (beyond Herman)."""
exclude = set(exclude or set())
active = _active_agent_keys()
low = (text or "").lower()
rules: list[tuple[tuple[str, ...], str]] = [
(("omzet", "marge", "kosten", "factuur", "cash", "btw", "budget", "winst", "prijs"), "finance"),
(("klant", "deal", "pipeline", "lead", "offerte", "sales", "partner", "b2b"), "bizdev"),
(("retail", "supermarkt", "schap", "ah ", "jumbo", "plus ", "lidl", "listing"), "retail"),
(("campagne", "merk", "content", "social", "branding", "advert"), "marketing"),
(("sku", "assortiment", "recept", "product", "formul"), "product"),
(("inkoop", "leverancier", "sourcing", "moq", "supplier"), "sourcing"),
(("halal", "certific", "kosher"), "halal"),
(("verpakking", "label", "packaging", "design"), "packaging"),
(("onderzoek", "marktanalyse", "research", "concurrent"), "research"),
(("hr", "personeel", "werving", "team"), "hr"),
(("sysops", "server", "deploy", "infra", "bug"), "sysops"),
(("web", "site", "landing", "seo"), "webbuilder"),
(("kennis", "document", "wiki"), "knowledge"),
(("email", "mail", "newsletter"), "email"),
(("browser", "scrape", "website check"), "browser"),
]
picked: list[str] = []
for words, key in rules:
if key in exclude or key not in active or key in picked:
continue
if any(w in low for w in words):
picked.append(key)
if len(picked) >= limit:
return picked
# fallback: rotate through core team not yet speaking
core = ["bizdev", "finance", "retail", "marketing", "product", "sourcing", "research"]
for key in core:
if key in exclude or key not in active or key in picked:
continue
picked.append(key)
if len(picked) >= limit:
break
return picked
def resolve_speakers(text: str, *, default_herman: bool = True) -> list[str]:
"""@mentions + soft name matches (bizdev/finance zonder @)."""
active = _active_agent_keys()
@@ -459,6 +669,11 @@ def resolve_speakers(text: str, *, default_herman: bool = True) -> list[str]:
# Alleen zonder @mentions: "meeting met jou en bizdev" → Herman erbij
if re.search(r"\b(jou|jullie|samen|meeting|overleg|bellen|call|afspraak)\b", low):
found.insert(0, "herman")
# Zonder expliciete @mentions: laat 1–2 relevante agents meedoen naast Herman
if found and not explicit:
for extra in _topic_contributors(text, exclude=set(found), limit=2):
if extra not in found:
found.append(extra)
return found[:MAX_REPLIES_PER_ROUND]
@@ -720,6 +935,7 @@ async def post_user_message(
role: str | None = None,
sender_avatar_url: str | None = None,
attachments: list[dict[str, Any]] | None = None,
reply_to_id: int | None = None,
) -> dict[str, Any]:
text = (content or "").strip()
files = _normalize_attachments(attachments)
@@ -733,6 +949,19 @@ async def post_user_message(
meta: dict[str, Any] = {"hermes_user": hermes_user}
if files:
meta["attachments"] = files
if reply_to_id:
parent = get_message(int(reply_to_id), room_id)
if parent:
excerpt = (parent.get("content") or "").strip()
if len(excerpt) > 220:
excerpt = excerpt[:219] + "…"
meta["reply_to"] = {
"id": parent.get("id"),
"agent_key": parent.get("agent_key"),
"username": parent.get("username"),
"sender_type": parent.get("sender_type"),
"excerpt": excerpt,
}
user_msg = _insert_message(
room_id,
@@ -841,17 +1070,28 @@ async def _agent_speak_stream(
chunks.append(delta)
yield ("token", {"agent_key": agent_key, "delta": delta})
reply = _sanitize_warroom_reply(agent_key, "".join(chunks))
meta = {
"llm": {"provider_type": "hermes", "hermes_user": agent_user, "streamed": True},
"in_reply_to": in_reply_to,
"model": "hermes-agent/mimo",
}
if parse_ceo_mentions(reply):
meta["ceo_direct"] = True
meta["ceo_alias"] = "aissa"
msg = _insert_message(
int(room["id"]),
sender_type="agent",
agent_key=agent_key,
content=reply,
metadata={
"llm": {"provider_type": "hermes", "hermes_user": agent_user, "streamed": True},
"in_reply_to": in_reply_to,
"model": "hermes-agent/mimo",
},
metadata=meta,
)
try:
inbox = record_ceo_direct(msg)
if inbox:
msg = dict(msg)
msg["ceo_inbox_id"] = inbox.get("id")
except Exception:
log.exception("ceo inbox record failed")
yield ("done", msg)
except Exception as exc:
log.exception("agent stream failed %s", agent_key)
@@ -872,6 +1112,7 @@ async def post_user_message_stream(
role: str | None = None,
sender_avatar_url: str | None = None,
attachments: list[dict[str, Any]] | None = None,
reply_to_id: int | None = None,
):
"""Async generator of SSE-ready event dicts for live typing."""
text = (content or "").strip()
@@ -886,6 +1127,22 @@ async def post_user_message_stream(
meta: dict[str, Any] = {"hermes_user": hermes_user}
if files:
meta["attachments"] = files
parent = None
if reply_to_id:
parent = get_message(int(reply_to_id), room_id)
if parent:
excerpt = (parent.get("content") or "").strip()
if len(excerpt) > 220:
excerpt = excerpt[:219] + "…"
meta["reply_to"] = {
"id": parent.get("id"),
"agent_key": parent.get("agent_key"),
"username": parent.get("username"),
"sender_type": parent.get("sender_type"),
"excerpt": excerpt,
}
user_msg = _insert_message(
room_id,
sender_type="user",
@@ -897,14 +1154,31 @@ async def post_user_message_stream(
yield {"event": "user", "message": user_msg}
trigger = _trigger_text(text, files)
if parent:
who = parent.get("agent_key") or parent.get("username") or "bericht"
excerpt = (meta.get("reply_to") or {}).get("excerpt") or ""
trigger = (
f"CEO Aissa REPLY op bericht van @{who}:\n"
f'\"{excerpt}\"\n\n'
f"Antwoord van Aissa: {trigger}\n\n"
f"BELANGRIJK: reageer DIRECT op dit reply van Aissa. Geen losse A2A-chat."
)
kind = room.get("kind")
speakers: list[str] = []
if kind == "dm":
key = room.get("agent_key")
if key:
speakers = [str(key)]
elif parent and parent.get("sender_type") == "agent" and parent.get("agent_key"):
# reply to agent → die agent eerst, max 1 extra contributor
primary = str(parent.get("agent_key"))
speakers = [primary]
for extra in _topic_contributors(text, exclude={primary}, limit=1):
speakers.append(extra)
else:
speakers = resolve_speakers(trigger, default_herman=True)
# op user-bericht: max 2 speakers (Herman + 1 relevant) — geen storm
speakers = resolve_speakers(trigger, default_herman=True)[:2]
produced: list[dict[str, Any]] = []
@@ -923,35 +1197,25 @@ async def post_user_message_stream(
produced.append(payload)
yield {"event": "system", "message": payload}
user_focus = (
f"Prioriteit: beantwoord EERST het bericht van {username or 'Aissa'}. "
f"Spreek hem aan als Aissa. "
f"Voor escalatie naar CEO-inbox: @aissa + concrete vraag. Daarna optioneel 1 @mention naar een collega."
)
for key in speakers:
async for ev in _emit_speak(key, trig=trigger):
async for ev in _emit_speak(key, trig=f"{trigger}\n\n{user_focus}"):
yield ev
# A2A rounds: agents @mention each other and answer with cockpit data
for _round in range(MAX_ROUNDS - 1):
nxt = _next_a2a_speakers(produced)
if not nxt:
# Force a light peer handoff so the room stays collaborative
last_agents = [m.get("agent_key") for m in produced if m.get("sender_type") == "agent"]
last = str(last_agents[-1]) if last_agents else (speakers[0] if speakers else "herman")
peers = [k for k in ("bizdev", "finance", "retail", "marketing", "herman") if k != last and k in _active_agent_keys()]
if not peers:
break
target = peers[_round % len(peers)]
nudge = (
f"War Room topic van {username or 'user'}: {trigger}\n"
f"@{last} heeft gesproken. Jij bent @{target}. Stel 1 korte vraag aan een peer OF geef 1 cockpit-feit + @mention wie moet antwoorden."
)
async for ev in _emit_speak(target, trig=nudge, reply_to=last):
yield ev
continue
for target, src in nxt:
follow = (
f"War Room topic van {username or 'user'}: {trigger}\n"
f"@{src} noemde je. Antwoord met cockpit-data en @mention zo nodig een andere agent."
)
async for ev in _emit_speak(target, trig=follow, reply_to=src):
yield ev
# Max 1 A2A follow-up — alleen bij expliciete @mention (geen geforceerde storm)
nxt = _next_a2a_speakers(produced)
for target, src in nxt[:1]:
follow = (
f"Bericht van {username or 'Aissa'}: {text}\n"
f"@{src} noemde je. Reageer kort op @{src} EN blijf relevant voor Aissa's bericht. "
f"Geen ellenlange ketting."
)
async for ev in _emit_speak(target, trig=follow, reply_to=src):
yield ev
yield {"event": "done", "ok": True, "a2a": True}
@@ -973,7 +1237,26 @@ async def continue_warroom_stream(
hermes_user = hermes_memory.resolve_hermes_user(username=username, role=role, channel="warroom")
recent = list_messages(room_id, limit=16)
if not recent:
raise ValueError("Geen gesprek om te vervolgen")
# stil kanaal: toch A2A kickstarten via Herman
topic = "Open A2A check-in: korte status + lichte food-mop, daarna @mention een collega."
produced = []
yield {"event": "continue_start", "topic": topic}
async for kind_ev, payload in _agent_speak_stream(
room, "herman", trigger=(
"Het is stil in de War Room. Start A2A: 1 korte professionele check-in "
"of milde food-mop. Spreek de CEO aan als Aissa; escalatie via @aissa. @mention @bizdev of @retail."
), hermes_user=hermes_user, in_reply_to=None
):
if kind_ev == "start":
yield {"event": "typing_start", **payload}
elif kind_ev == "token":
yield {"event": "typing", **payload}
elif kind_ev == "done":
yield {"event": "agent", "message": payload}
elif kind_ev == "error":
yield {"event": "system", "message": payload}
yield {"event": "done", "ok": True, "continued": True}
return
# Find last user topic
topic = ""
@@ -982,7 +1265,7 @@ async def continue_warroom_stream(
topic = (m.get("content") or "").strip()
break
if not topic:
topic = "Blijf het Foodlinkk cockpit-dossier bespreken (pipeline, deals, retail, finance)."
topic = "Volg Aissa's input als leidraad; spreek hem aan als Aissa; wissel cockpit-dossier met lichte humor; escaleer alleen met @aissa als echt nodig."
produced = [m for m in recent if m.get("sender_type") == "agent"][-6:]
yield {"event": "continue_start", "topic": topic[:200]}
@@ -991,20 +1274,37 @@ async def continue_warroom_stream(
nxt = _next_a2a_speakers(produced)
if not nxt:
last = str((produced[-1] or {}).get("agent_key") or "herman") if produced else "herman"
peers = [k for k in ("herman", "bizdev", "finance", "retail", "marketing", "product") if k != last and k in _active_agent_keys()]
spoken = {str(m.get("agent_key") or "") for m in produced}
spoken.add(last)
contrib = _topic_contributors(topic, exclude=spoken, limit=2)
peers = contrib or [
k for k in (
"herman", "bizdev", "finance", "retail", "marketing",
"product", "sourcing", "research", "halal", "packaging",
)
if k not in spoken and k in _active_agent_keys()
]
if not peers:
peers = [
k for k in ("herman", "bizdev", "finance", "retail", "marketing", "product")
if k != last and k in _active_agent_keys()
]
if not peers:
break
target = peers[turn % len(peers)]
src = last
trig = (
f"Autonoom War Room-overleg (user kan meelezen). Topic: {topic}\n"
f"Jij bent @{target}. Reageer op @{src} met 1 cockpit-feit en @mention 1 collega met een gerichte vraag."
f"Autonoom War Room A2A (user kan meelezen). Topic: {topic}\n"
f"Jij bent @{target}. Reageer ECHT op @{src}.\n"
f"Kies passend: (a) 1 cockpit-feit + gerichte vraag, OF (b) korte respectvolle food-mop + @mention terug naar de inhoud.\n"
f"Breng JOUW expertise in (niet herhalen wat @{src} zei). Spreek de CEO aan als Aissa. Altijd @mention 1 collega; escalatie naar CEO-inbox via @aissa. Max 4 zinnen. Natuurlijk NL."
)
else:
target, src = nxt[0]
trig = (
f"Autonoom War Room-overleg. Topic: {topic}\n"
f"@{src} vroeg jouw input. Antwoord met cockpit-data en @mention desnoods een andere agent."
f"Autonoom War Room A2A. Topic: {topic}\n"
f"@{src} sprak je aan. Reageer op die inhoud (of mop) alsof je in een echt teamoverleg zit.\n"
f"Geef waarde vanuit JOUW rol (feit/actie/vraag). Trek er 1 collega bij die iets kan toevoegen via @mention — niet alleen Herman. Korte food-humor mag."
)
async for kind_ev, payload in _agent_speak_stream(
+139
View File
@@ -0,0 +1,139 @@
"""Agents NAS workspace — alle agent-data op //10.4.7.11/share/Agents."""
from __future__ import annotations
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
AGENTS_NAS_ROOT = Path(os.getenv("NAS_AGENTS_ROOT", "/data/nas-share/Agents"))
AGENTS_NAS_UNC = os.getenv("NAS_AGENTS_UNC", "//10.4.7.11/share/Agents")
AGENTS_NAS_SMB = os.getenv("NAS_AGENTS_SMB", "//10.4.7.11/share/Agents")
AGENT_SUBDIRS = ("notes", "exports", "memory", "inbox", "outbox")
def slugify(name: str, max_len: int = 40) -> str:
s = re.sub(r"[^a-zA-Z0-9._-]+", "-", (name or "file").strip()).strip("-._")
return (s[:max_len] or "file")
def root() -> Path:
return AGENTS_NAS_ROOT
def unc() -> str:
return AGENTS_NAS_UNC
def smb() -> str:
return AGENTS_NAS_SMB
def ensure_root() -> Path:
AGENTS_NAS_ROOT.mkdir(parents=True, exist_ok=True)
sys_dir = AGENTS_NAS_ROOT / "_system"
sys_dir.mkdir(parents=True, exist_ok=True)
readme = sys_dir / "README.txt"
if not readme.exists():
readme.write_text(
"Foodlinkk Agents NAS workspace\n"
f"Pad: {AGENTS_NAS_SMB}\n"
"Elke agent schrijft ALLEEN in Agents/<agent_key>/...\n"
"Submappen: notes, exports, memory, inbox, outbox\n",
encoding="utf-8",
)
meta = {
"unc": AGENTS_NAS_UNC,
"smb": AGENTS_NAS_SMB,
"container_path": str(AGENTS_NAS_ROOT),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
(sys_dir / "workspace.json").write_text(
json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8"
)
return AGENTS_NAS_ROOT
def ensure_agent_folder(agent_key: str) -> Path:
ensure_root()
key = slugify(agent_key or "unknown", 32).lower()
base = AGENTS_NAS_ROOT / key
for sub in AGENT_SUBDIRS:
(base / sub).mkdir(parents=True, exist_ok=True)
meta = {
"agent_key": key,
"path": str(base),
"smb": f"{AGENTS_NAS_SMB}/{key}",
"subdirs": list(AGENT_SUBDIRS),
}
(base / "agent.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
return base
def bootstrap_all(agent_keys: list[str] | None = None) -> dict[str, Any]:
ensure_root()
keys = agent_keys or []
created = []
for k in keys:
p = ensure_agent_folder(str(k))
created.append(str(p))
return {
"ok": True,
"root": str(AGENTS_NAS_ROOT),
"smb": AGENTS_NAS_SMB,
"agents": created,
"count": len(created),
}
def memory_rule_text() -> str:
return (
"NAS DATA (HARDCODED — VERPLICHT):\n"
"- Alle bestanden/data die jij creëert schrijf je naar de Agents-map op de FL-nas.\n"
f"- Pad: {AGENTS_NAS_SMB}/<jouw_agent_key>/\n"
f"- Server/container pad: {AGENTS_NAS_ROOT}/<jouw_agent_key>/\n"
"- Submappen: notes/ (notities), exports/ (rapporten), memory/ (lange-termijn), "
"inbox/ (inkomend), outbox/ (uitgaand).\n"
"- NOOIT alleen in /tmp laten staan — altijd op deze NAS-map opslaan.\n"
"- Noem in je antwoord het NAS-pad als je iets hebt opgeslagen.\n"
)
def write_text(agent_key: str, relative_path: str, content: str, *, subdir: str = "notes") -> dict[str, Any]:
base = ensure_agent_folder(agent_key)
sub = subdir if subdir in AGENT_SUBDIRS else "notes"
rel = relative_path.lstrip("/\\").replace("..", "")
if not rel:
rel = f"note-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.txt"
target = (base / sub / rel).resolve()
if not str(target).startswith(str(base.resolve())):
raise ValueError("Pad buiten agent-map niet toegestaan")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content if content.endswith("\n") else content + "\n", encoding="utf-8")
key = slugify(agent_key).lower()
return {
"ok": True,
"path": str(target),
"smb": f"{AGENTS_NAS_SMB}/{key}/{sub}/{rel}",
}
def write_json(agent_key: str, relative_path: str, data: Any, *, subdir: str = "exports") -> dict[str, Any]:
name = relative_path if relative_path.endswith(".json") else relative_path + ".json"
return write_text(
agent_key,
name,
json.dumps(data, indent=2, ensure_ascii=False, default=str),
subdir=subdir,
)
def agent_paths(agent_key: str) -> dict[str, str]:
key = slugify(agent_key or "unknown", 32).lower()
return {
"root": str(AGENTS_NAS_ROOT / key),
"smb": f"{AGENTS_NAS_SMB}/{key}",
}
+26 -1
View File
@@ -8,7 +8,7 @@ from typing import Any, Optional
import httpx
from app.db import fetch_all, fetch_one
from app.services import agent_integration, agent_souls, herman, webbuilder_agent
from app.services import agent_integration, agent_nas, agent_souls, herman, webbuilder_agent
from app.services.agent_events_log import log_agent_event
from app.services.agent_names import normalize_agent_key
@@ -24,6 +24,8 @@ GLOBAL_HELP = [
"handoff <agent> <type> [notitie]",
"say <bericht> — log een notitie",
"ask <vraag> — AI-antwoord in rol van deze agent",
"nas — toon Agents NAS-pad",
"save <bestand> <tekst> — schrijf naar NAS Agents/<agent>/notes/",
]
AGENT_HELP: dict[str, list[str]] = {
@@ -430,6 +432,29 @@ async def execute_terminal_command(
else:
note = " ".join(args[2:]) if len(args) > 2 else ""
lines = _handoff(key, args[0], args[1], note)
elif cmd == "nas":
try:
agent_nas.ensure_agent_folder(key)
paths = agent_nas.agent_paths(key)
lines = [
_line("Agents NAS: " + str(agent_nas.smb())),
_line("Jouw map: " + str(paths.get("smb") or "")),
_line("Container: " + str(paths.get("root") or "")),
_line("Sub: notes/ exports/ memory/ inbox/ outbox/"),
]
except Exception as exc:
lines = [_line("NAS fout: " + str(exc), line_type="error")]
elif cmd == "save":
if len(args) < 2:
lines = [_line("Gebruik: save <bestandsnaam> <tekst>", line_type="error")]
else:
fname = args[0]
body = " ".join(args[1:])
try:
result = agent_nas.write_text(key, fname, body, subdir="notes")
lines = [_line("Opgeslagen op NAS: " + str(result.get("smb") or result.get("path")))]
except Exception as exc:
lines = [_line("Save mislukt: " + str(exc), line_type="error")]
elif cmd == "say":
if not rest:
lines = [_line("Gebruik: say <bericht>", line_type="error")]