diff --git a/tests/test_agent_chat_router.py b/tests/test_agent_chat_router.py index fe4e6492..928c3183 100644 --- a/tests/test_agent_chat_router.py +++ b/tests/test_agent_chat_router.py @@ -522,3 +522,93 @@ async def test_lead_who_is_author_does_not_receive_own_message(): slugs = [c[0] for c in bridge.calls] assert "coord" not in slugs + + +# --------------------------------------------------------------------------- +# System context (agent_manual) injection +# --------------------------------------------------------------------------- + +def _a2a_project_channel_with_leads(members, leads, mode="quiet"): + return { + "id": "c1", + "type": "group", + "project_id": "proj-1", + "members": members, + "settings": { + "kind": "a2a", + "response_mode": mode, + "leads": leads, + "max_hops": 3, + "cooldown_seconds": 5, + "rate_cap_per_minute": 20, + "muted": [], + }, + } + + +@pytest.mark.asyncio +async def test_manual_injected_as_first_system_message(): + """context[0] passed to the bridge must be a system-role manual message.""" + bridge = _FakeBridge() + state = _state_for({"name": "coord", "status": "running"}, bridge=bridge) + state.config.agents = [{"name": "coord", "status": "running"}] + from tinyagentos.chat.group_policy import GroupPolicy + state.group_policy = GroupPolicy() + router = AgentChatRouter(state) + + msg = {"id": "m1", "author_id": "user", "author_type": "user", + "content": "@coord go", "metadata": {"hops_since_user": 0}} + ch = _a2a_project_channel_with_leads(["user", "coord"], leads=["coord"]) + await router._route(msg, ch) + + assert len(bridge.calls) == 1 + _, enqueued = bridge.calls[0] + ctx = enqueued["context"] + assert ctx, "context must not be empty" + first = ctx[0] + assert first["role"] == "system" + # Spot-check for known manual content. + assert "@-mention routing" in first["content"] + assert "kanban board" in first["content"] + + +@pytest.mark.asyncio +async def test_manual_lead_branch_correct_for_lead(): + """When the recipient is a lead, the lead branch text appears.""" + bridge = _FakeBridge() + state = _state_for({"name": "coord", "status": "running"}, bridge=bridge) + state.config.agents = [{"name": "coord", "status": "running"}] + from tinyagentos.chat.group_policy import GroupPolicy + state.group_policy = GroupPolicy() + router = AgentChatRouter(state) + + msg = {"id": "m1", "author_id": "user", "author_type": "user", + "content": "@coord go", "metadata": {"hops_since_user": 0}} + ch = _a2a_project_channel_with_leads(["user", "coord"], leads=["coord"]) + await router._route(msg, ch) + + _, enqueued = bridge.calls[0] + manual = enqueued["context"][0]["content"] + assert "You ARE designated lead" in manual + assert "You are NOT a lead" not in manual + + +@pytest.mark.asyncio +async def test_manual_non_lead_branch_correct_for_worker(): + """When the recipient is not a lead, the non-lead branch text appears.""" + bridge = _FakeBridge() + state = _state_for({"name": "worker", "status": "running"}, bridge=bridge) + state.config.agents = [{"name": "worker", "status": "running"}] + from tinyagentos.chat.group_policy import GroupPolicy + state.group_policy = GroupPolicy() + router = AgentChatRouter(state) + + msg = {"id": "m1", "author_id": "user", "author_type": "user", + "content": "@worker do it", "metadata": {"hops_since_user": 0}} + ch = _a2a_project_channel_with_leads(["user", "worker"], leads=["coord"]) + await router._route(msg, ch) + + _, enqueued = bridge.calls[0] + manual = enqueued["context"][0]["content"] + assert "You are NOT a lead" in manual + assert "You ARE designated lead" not in manual diff --git a/tests/test_agent_manual.py b/tests/test_agent_manual.py new file mode 100644 index 00000000..a746ee85 --- /dev/null +++ b/tests/test_agent_manual.py @@ -0,0 +1,173 @@ +"""Tests for tinyagentos.agent_manual.build_manual.""" +import pytest + +from tinyagentos.agent_manual import build_manual + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _dm_channel(): + return {"id": "c1", "type": "dm", "settings": {}} + + +def _group_channel(): + return {"id": "c2", "type": "group", "settings": {"response_mode": "quiet"}} + + +def _a2a_project_channel(leads=None): + return { + "id": "c3", + "type": "group", + "project_id": "proj-1", + "settings": { + "kind": "a2a", + "leads": leads or [], + }, + } + + +def _token_estimate(text: str) -> float: + """Simple heuristic: words * 1.3 (matches spec).""" + return len(text.split()) * 1.3 + + +# --------------------------------------------------------------------------- +# DM channel +# --------------------------------------------------------------------------- + +class TestDMChannel: + def test_dm_has_header(self): + text = build_manual(_dm_channel(), "alice", []) + assert "taOS" in text + assert "operating manual" in text + + def test_dm_has_dm_stub(self): + text = build_manual(_dm_channel(), "alice", []) + assert "1-on-1" in text or "direct-message" in text + + def test_dm_no_verbs_section(self): + # The verbs section header must not appear (stub may mention the word) + text = build_manual(_dm_channel(), "alice", []) + assert "## 2. Project task verbs" not in text + assert "/new" not in text + + def test_dm_no_lead_section(self): + text = build_manual(_dm_channel(), "alice", []) + assert "Lead vs non-lead" not in text + + def test_dm_no_mention_routing_section(self): + # The section header must not appear (stub mentions the term to say it doesn't apply) + text = build_manual(_dm_channel(), "alice", []) + assert "## 1. @-mentions are how messages route" not in text + + def test_dm_token_budget(self): + text = build_manual(_dm_channel(), "alice", []) + assert _token_estimate(text) <= 200, ( + f"DM manual exceeds 200-token budget: {_token_estimate(text):.0f} tokens" + ) + + +# --------------------------------------------------------------------------- +# Group channel without project_id +# --------------------------------------------------------------------------- + +class TestGroupChannelNoProject: + def test_has_mention_section(self): + text = build_manual(_group_channel(), "alice", []) + assert "@-mention routing" in text + + def test_has_quick_ref_section(self): + text = build_manual(_group_channel(), "alice", []) + assert "Quick reference" in text + + def test_no_task_verbs_section(self): + text = build_manual(_group_channel(), "alice", []) + assert "kanban board" not in text + + def test_no_lead_section(self): + text = build_manual(_group_channel(), "alice", []) + assert "Lead vs non-lead" not in text + + +# --------------------------------------------------------------------------- +# Project a2a channel — lead agent +# --------------------------------------------------------------------------- + +class TestProjectA2AChannelLead: + def setup_method(self): + self.channel = _a2a_project_channel(leads=["coord"]) + self.text = build_manual(self.channel, "coord", ["coord"]) + + def test_has_mention_section(self): + assert "@-mention routing" in self.text + + def test_has_task_verbs_section(self): + assert "kanban board" in self.text + + def test_has_lead_section(self): + assert "Lead vs non-lead" in self.text + + def test_lead_branch_text(self): + assert "You ARE designated lead" in self.text + + def test_has_quick_ref_section(self): + assert "Quick reference" in self.text + + def test_no_non_lead_text(self): + assert "You are NOT a lead" not in self.text + + +# --------------------------------------------------------------------------- +# Project a2a channel — non-lead agent +# --------------------------------------------------------------------------- + +class TestProjectA2AChannelNonLead: + def setup_method(self): + self.channel = _a2a_project_channel(leads=["coord"]) + self.text = build_manual(self.channel, "worker", ["coord"]) + + def test_has_mention_section(self): + assert "@-mention routing" in self.text + + def test_has_task_verbs_section(self): + assert "kanban board" in self.text + + def test_has_lead_section(self): + assert "Lead vs non-lead" in self.text + + def test_non_lead_branch_text(self): + assert "You are NOT a lead" in self.text + + def test_has_quick_ref_section(self): + assert "Quick reference" in self.text + + def test_no_lead_text(self): + assert "You ARE designated lead" not in self.text + + +# --------------------------------------------------------------------------- +# Project a2a channel — empty leads list +# --------------------------------------------------------------------------- + +class TestProjectA2AEmptyLeads: + def test_empty_leads_gives_non_lead_branch(self): + channel = _a2a_project_channel(leads=[]) + text = build_manual(channel, "worker", []) + assert "You are NOT a lead" in text + assert "You ARE designated lead" not in text + + +# --------------------------------------------------------------------------- +# Token budget — longest branch (lead in project a2a) +# --------------------------------------------------------------------------- + +class TestTokenBudget: + def test_longest_branch_under_500_tokens(self): + channel = _a2a_project_channel(leads=["coord"]) + text = build_manual(channel, "coord", ["coord"]) + estimate = _token_estimate(text) + assert estimate <= 500, ( + f"Manual exceeds 500-token budget: {estimate:.0f} estimated tokens" + ) diff --git a/tinyagentos/agent_chat_router.py b/tinyagentos/agent_chat_router.py index d340693d..2ec77db5 100644 --- a/tinyagentos/agent_chat_router.py +++ b/tinyagentos/agent_chat_router.py @@ -142,6 +142,9 @@ async def _route_inner(self, message: dict, channel: dict) -> None: logger.warning("context fetch failed for channel %s", channel.get("id"), exc_info=True) context = [] + leads = list((settings.get("leads") or [])) # post-PR #291 + from tinyagentos.agent_manual import build_manual + for agent_name in recipients: forced = force_by_slug.get(agent_name, False) if not forced: @@ -165,6 +168,8 @@ async def _route_inner(self, message: dict, channel: dict) -> None: ) continue + manual_text = build_manual(channel, agent_name, leads) + agent_context = [{"role": "system", "content": manual_text}, *context] await bridge.enqueue_user_message( agent_name, { @@ -176,7 +181,7 @@ async def _route_inner(self, message: dict, channel: dict) -> None: "created_at": message.get("created_at"), "hops_since_user": next_hops, "force_respond": forced, - "context": context, + "context": agent_context, "thread_id": thread_id, }, ) diff --git a/tinyagentos/agent_manual.py b/tinyagentos/agent_manual.py new file mode 100644 index 00000000..05a6cf61 --- /dev/null +++ b/tinyagentos/agent_manual.py @@ -0,0 +1,124 @@ +"""Always-on system context injector. + +build_manual returns a markdown string that is prepended as a system-role +message to every agent's context window at chat-dispatch time. It is a +pure function: no IO, no globals beyond the constant strings below. +""" +from __future__ import annotations + +_HEADER = """\ +# taOS — operating manual (always-on system context) + +You are running inside taOS. You communicate with humans and other agents +via chat channels. Read these primitives carefully — getting them right +is the difference between productive collaboration and silent dropouts. +""" + +_SECTION_MENTIONS = """\ +## 1. @-mentions are how messages route + +This channel uses @-mention routing. A reply with no @-tag reaches NO ONE. + +- `@` — addresses one agent by name +- `@all` — addresses every agent in the channel +- `@humans` — pings the human(s); no agent reply + +When handing off work, ALWAYS @-tag the next agent in your reply. +Without an @-tag the channel does not route and the conversation stalls. + +If you @-tag a teammate and they go silent for two turns, tag them again +with a polite nudge. +""" + +_SECTION_TASK_VERBS = """\ +## 2. Project task verbs (kanban board) + +In a project chat channel, drive the kanban board with these single-line +verbs anywhere in your message: + +- `/new "" [@<assignee>]` — create a new task; optional assignee +- `/claim <tsk-id>` — claim a ready task as your own +- `/release <tsk-id>` — release a claimed task back to ready +- `/close <tsk-id> [<note>]` — close a task with optional outcome note + +The kanban board updates live. Task ids look like `tsk-abc123` and the +system announces them when created. +""" + +_SECTION_LEAD_IS = """\ +## 3. Lead vs non-lead + +You ARE designated lead on this project. You receive every message in +this channel regardless of @-mentions. Drive task allocation; chase +silent teammates with @-mentions; see the project through to delivery. +""" + +_SECTION_NON_LEAD = """\ +## 3. Lead vs non-lead + +You are NOT a lead on this project. You only see messages where you're +explicitly @-tagged. Always @-tag the next agent or the lead when you +hand off your work. +""" + +_SECTION_QUICK_REF = """\ +## 4. Quick reference + +| You want to… | Do this | +|---|---| +| Address one agent | `@<name>` | +| Address everyone | `@all` | +| Create a task | `/new "<title>" @<name>` | +| Claim a task | `/claim tsk-XXX` | +| Close a task | `/close tsk-XXX <note>` | +| Hand off work | Reply with `@<next-agent>` + deliverable / task id | + +Full chat guide: `/docs/chat-guide`. +""" + +_DM_STUB = """\ +This is a 1-on-1 direct-message channel. Both you and the human always +see every message here. No @-mention routing or kanban verbs apply. +""" + + +def build_manual(channel: dict, agent_name: str, leads: list[str]) -> str: + """Return the operating manual for *agent_name* in *channel*. + + Args: + channel: The channel dict (needs ``type``, ``settings``, + ``project_id`` keys — missing keys are handled safely). + agent_name: Recipient agent's slug/name. + leads: List of lead agent names from ``channel.settings.leads``. + + Returns: + A markdown string suitable for a ``role: "system"`` message. + """ + channel_type = channel.get("type") or "" + project_id = channel.get("project_id") + settings = channel.get("settings") or {} + is_a2a = settings.get("kind") == "a2a" + + # DM: header + stub only. + if channel_type == "dm": + return _HEADER + _DM_STUB + + # Group / topic channels. + parts = [_HEADER] + + # Section 1: @-mention routing (group and topic channels). + if channel_type in ("group", "topic"): + parts.append(_SECTION_MENTIONS) + + # Sections 2 + 3: project a2a only. + if project_id and is_a2a: + parts.append(_SECTION_TASK_VERBS) + if agent_name in leads: + parts.append(_SECTION_LEAD_IS) + else: + parts.append(_SECTION_NON_LEAD) + + # Section 4: always for non-DM. + parts.append(_SECTION_QUICK_REF) + + return "\n".join(parts) diff --git a/tinyagentos/routes/cluster.py b/tinyagentos/routes/cluster.py index 04c404f5..fb549738 100644 --- a/tinyagentos/routes/cluster.py +++ b/tinyagentos/routes/cluster.py @@ -71,6 +71,13 @@ async def list_workers(request: Request): result = [] for w in workers: d = asdict(w) + # signing_key is the worker's raw HMAC secret (bytes). Two reasons + # to strip it from API responses: (1) FastAPI's default encoder + # tries to utf-8 decode bytes fields, which crashes on random key + # material — the entire workers list 500s when any worker has a + # non-utf8 signing key, and (2) even when serialization didn't + # crash, the secret has no business being on the wire. + d.pop("signing_key", None) if registry is not None: tier_id, pot_caps = _potential_capabilities(w.hardware, registry) d["tier_id"] = tier_id