From b7d1fb7e94fc89b8ac24b0d2ccf5109ef8ceee91 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Fri, 1 May 2026 16:31:25 +0100 Subject: [PATCH 1/2] feat(chat): inject always-on system context manual into every agent dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds agent_manual.py with build_manual() — a pure function that returns a per-agent operating manual (≤500 tokens) covering @-mention routing, project task verbs, and lead/non-lead status. The router prepends it as role:system before the conversation history on every chat dispatch so agents always know the channel primitives without per-session briefs. --- tests/test_agent_chat_router.py | 90 ++++++++++++++++ tests/test_agent_manual.py | 173 +++++++++++++++++++++++++++++++ tinyagentos/agent_chat_router.py | 7 +- tinyagentos/agent_manual.py | 124 ++++++++++++++++++++++ 4 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 tests/test_agent_manual.py create mode 100644 tinyagentos/agent_manual.py 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) From 9b4154ea74f1b2a633d0c40e783137ce4d850838 Mon Sep 17 00:00:00 2001 From: jaylfc <jaylfc25@gmail.com> Date: Fri, 1 May 2026 17:54:57 +0100 Subject: [PATCH 2/2] fix(cluster): strip signing_key from /api/cluster/workers response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit asdict(WorkerInfo) dumps the worker's raw HMAC signing_key bytes into the response. FastAPI's default jsonable_encoder maps bytes via o.decode() with implicit utf-8 — random key material has bytes outside the utf-8 range, so the entire endpoint 500s. Visible symptom: workers disappear from the cluster widget on the dashboard because the list endpoint never succeeds. Beyond the crash, leaking the HMAC signing key over the API has no legitimate purpose. Strip it from the response. Confirmed on Pi: GET /api/cluster/workers was returning 500 with "UnicodeDecodeError: 'utf-8' codec can't decode byte 0xa2..." every poll. Fedora worker (192.168.6.108) heartbeats are fine — the bug was purely in the list-side serialization. --- tinyagentos/routes/cluster.py | 7 +++++++ 1 file changed, 7 insertions(+) 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