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:
@@ -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(
|
||||
|
||||
@@ -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}",
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user