Skip to content

Chat Phase 1 — multi-agent routing, mentions, policy, reactions, bare-slash guardrail#234

Merged
jaylfc merged 30 commits intomasterfrom
feat/chat-phase-1-conversational-core
Apr 19, 2026
Merged

Chat Phase 1 — multi-agent routing, mentions, policy, reactions, bare-slash guardrail#234
jaylfc merged 30 commits intomasterfrom
feat/chat-phase-1-conversational-core

Conversation

@jaylfc
Copy link
Copy Markdown
Owner

@jaylfc jaylfc commented Apr 19, 2026

Summary

Transforms the parallel 1:1-per-agent chat into a functional multi-agent room:

  • Agents see each other's messages (router now re-dispatches agent replies)
  • Channels have a response mode: quiet (respond only when @mentioned, the default) or lively (every agent gets to decide per-message via inline NO_RESPONSE)
  • @mentions (@slug, @all, @humans) drive addressing; @mention overrides cooldown/hop cap
  • Loop prevention: per-agent cooldown (5s default), channel-wide rate cap (20/min), hop counter (max 3 since last user turn)
  • Each bridge event carries a rolling context window (20 msgs or 4000 tokens) so agents actually see the conversation
  • Semantic reactions: 👎 by a human on an agent message → regenerate; 🙋 by an agent → wants-reply flag (5 min TTL)
  • Control-plane via REST admin endpoints driven by the UI's right-click menus (Phase 2), NOT via slash-command interception — avoids colliding with agent-framework / namespaces (OpenClaw / SmolAgents / Hermes all ship their own / commands)
  • Bare-slash guardrail: in a non-DM channel, a /-prefixed message must @<agent> or @all, else 400. Prevents accidental broadcast of framework commands.

Deferred to Phase 2 (separate brainstorm): slash autocomplete menu in the composer, per-framework command discovery.

Spec + plan

New modules

  • tinyagentos/chat/mentions.pyparse_mentions + MentionSet dataclass
  • tinyagentos/chat/group_policy.py — per-agent cooldown + per-channel rate cap tracker
  • tinyagentos/chat/context_window.py — rolling conversation-history builder
  • tinyagentos/chat/reactions.py — semantic reactions + WantsReplyRegistry

Key files modified

  • tinyagentos/agent_chat_router.py — mention-aware fanout, hop propagation, policy gates
  • tinyagentos/bridge_session.py — event payload carries hops_since_user / force_respond / context; agent replies re-dispatch so other agents see them
  • tinyagentos/routes/chat.py — bare-slash guardrail, reactions POST/DELETE, channel admin PATCH/POST endpoints
  • tinyagentos/chat/channel_store.py — default Phase 1 settings backfill + mutation helpers (set_response_mode, set_max_hops, set_cooldown_seconds, mute_agent, unmute_agent)
  • tinyagentos/scripts/install_*.sh — all 6 bridges render context, honour force_respond, suppress NO_RESPONSE replies

Also in this PR (docs / infra)

  • README pre-beta notice updated: 6-framework group chat working; beta date corrected to "week of Mon 20 April"
  • taOSmd paragraph rewritten to match the current benchmark framing (97.0% end-to-end Judge accuracy, apples-to-apples caveat vs MemPalace / agentmemory Recall@5)
  • rolling-images release moved out of the main repo to jaylfc/tinyagentos-images so the Releases page is reserved for real taOS releases. Deployer URL updated. Old main-repo release deleted.

Test plan

  • Unit tests for each new module (mentions, group_policy, context_window, reactions)
  • Router integration tests: fanout, quiet/lively modes, @mentions, @ALL, cooldown, hop cap, DM force-respond
  • Bare-slash guardrail tests (5 scenarios: bare / in group → 400; @<slug> /cmd → ok; @all /cmd → ok; / in DM → ok; non-slash in group → ok)
  • Channel admin endpoint tests (PATCH response_mode/max_hops/topic, POST members add/remove, POST muted add/remove, error paths)
  • Bridge session phase1 tests: event payload carries new fields; agent reply metadata carries hops
  • Full suite: 1907 pass, 3 pre-existing unrelated failures (macOS hardware-arch test), 13 skipped
  • E2E roundtable test (env-gated; runs against a live Pi with TAOS_E2E_URL/TOKEN/CHANNEL)

Caveats worth a reviewer eye

  • Streaming re-dispatch gap (intentional, documented). The _handle_reply delta→final streaming path doesn't re-dispatch (persisted stays None). All current bridges emit kind=final only, so it's not a live issue. Revisit when a streaming bridge is added.
  • WebSocket chat path. The existing chat_ws handler at routes/chat.py persists messages directly; it doesn't apply the bare-slash guardrail. If the frontend sends chat over WS rather than HTTP POST, that path is a bypass. UI today uses POST for user messages so this is not a live gap, but a follow-up to harden if the picture changes.

Follow-ups (separate issues/brainstorms)

  • Phase 2 UX: right-click agent → context menu (mute/remove/DM); channel settings popover (mode/topic/rename/members); typing indicators; read receipts; threads; pinning
  • Phase 2 slash menu: / opens autocomplete grouped by agent (tom: /help, /clear …); picking sends as @<slug> /<cmd>
  • Per-framework slash-command discovery (static manifest in frameworks.py as the first iteration)
  • Length caps on /topic (currently 500) and /rename (currently 100) already enforced in the REST admin path; review if too tight

Summary by CodeRabbit

  • New Features

    • Slash-command guardrail: bare / in group channels must target agents or is rejected
    • Channel admin REST endpoints to update settings, members, and muted list
    • Multi-agent routing improvements: hop limits, cooldown/rate policy, DM forcing, muted skipping, mention parsing, and context-aware agent replies
    • Reaction semantics and wants-reply registry: 👎 can trigger regeneration; 🙋 records reply intent
  • Documentation

    • Updated chat phase‑1 spec reflecting guardrail and admin-driven controls
  • Tests

    • Extensive new and updated tests plus a slow E2E lively roundtable test

jaylfc added 24 commits April 19, 2026 12:00
…e/summon/quiet/lively/hops/cooldown/topic/rename/help)
…in repo

Pre-built LXC base images are deployer infrastructure, not user-facing
releases. Move them to a dedicated repo so the main tinyagentos Releases
page is reserved for actual taOS releases.

Workflow publishes to jaylfc/tinyagentos-images via IMAGES_RELEASE_TOKEN
(fine-grained PAT with Contents: write on that repo). Deployer
(tinyagentos/agent_image.py) now pulls from the new repo's release URL.
…ents

Agent frameworks have their own / command namespaces (/help, /clear, etc.);
taOS intercepting / would collide with them indefinitely. Control-plane
actions (mute, mode, topic, etc.) move to REST endpoints driven by UI
right-click menus instead. See follow-up commits.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 19, 2026

📝 Walkthrough

Walkthrough

Replaces backend slash-command interception with a bare-slash guardrail and UI-driven REST channel admin endpoints; adds mention-aware routing with hop limits and group policy, context-window construction, semantic reaction handling, bridge changes for forced/context replies, channel-store settings, and extensive new tests.

Changes

Cohort / File(s) Summary
Spec & Config
docs/superpowers/specs/2026-04-19-chat-phase-1-conversational-core-design.md, pyproject.toml
Spec updated to reflect removal of slash-command interception and introduction of admin REST endpoints; added pytest slow marker.
Routing & Sessions
tinyagentos/agent_chat_router.py, tinyagentos/bridge_session.py, tinyagentos/app.py
Router now parses mentions, applies DM/quiet/lively semantics, enforces hop caps, respects muted members, consults GroupPolicy, builds context for enqueues; bridge tracks per-trace hops, re-dispatches persisted agent replies to router; app wires GroupPolicy and WantsReplyRegistry into app.state.
Channel Store & Message Parsing
tinyagentos/chat/channel_store.py, tinyagentos/chat/message_store.py
Channels are backfilled with PHASE1_DEFAULT_SETTINGS; added setters for response_mode/max_hops/cooldown and mute/unmute helpers; message parsing now ensures metadata is non-null.
New Chat Utilities
tinyagentos/chat/mentions.py, tinyagentos/chat/group_policy.py, tinyagentos/chat/context_window.py, tinyagentos/chat/reactions.py
Added mention parser and MentionSet, in-memory GroupPolicy (cooldown & rate-cap), context-window builder and token estimator, WantsReplyRegistry, and semantic reaction handling (👎 enqueues regenerate, 🙋 records wants-reply).
Routes / API Guardrails
tinyagentos/routes/chat.py
Reworked POST /api/chat/messages to reject bare /-prefixed content in non-DM channels unless explicitly mentioned; replaced old member add flow with PATCH /api/chat/channels/{id} and POST endpoints for members/muted management; added reaction endpoints and wants-reply listing; consistent channel_id/content handling.
Bridge Scripts
tinyagentos/scripts/... (install_*.sh series)
Embedded bridge scripts changed to accept force_respond and optional context, render context into prompts, conditionally suppress NO_RESPONSE outputs when not forced, and include channel_id when posting replies.
Tests — Integration & E2E
tests/e2e/test_roundtable_group_chat.py, tests/test_routes_chat_admin.py, tests/test_routes_chat_slash_guard.py
Added slow E2E roundtable test; admin endpoint tests with validation and membership/muting flows; slash-guard tests covering group/DM/mention permutations.
Tests — Router, Policy, Mentions, Context, Reactions, Stores
tests/test_agent_chat_router.py, tests/test_chat_group_policy.py, tests/test_chat_mentions.py, tests/test_chat_context_window.py, tests/test_chat_reactions.py, tests/test_chat_channel_settings.py, tests/test_chat_channels.py, tests/test_chat_messages.py, tests/test_bridge_session_phase1.py
Extensive unit tests added/updated: routing fanout (quiet/lively/DM), hops & hop-cap, cooldown/rate-cap behavior, mention parsing, context-window trimming, reaction semantics, channel settings persistence, message metadata hops, and bridge session hop propagation.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant API as POST /api/chat/messages
    participant Parser as parse_mentions()
    participant DB as MessageStore
    participant Router as AgentChatRouter
    participant Policy as GroupPolicy
    participant Context as build_context_window()
    participant Bridge as BridgeSessionRegistry
    participant Agent

    User->>API: POST (content, channel_id)
    API->>Parser: parse_mentions(content, members)
    Parser-->>API: MentionSet
    alt Non-DM & bare-slash & no mention
        API-->>User: HTTP 400 (must address an agent)
    else Valid message
        API->>DB: persist message
        DB-->>API: persisted_message
        API->>Router: dispatch(persisted_message, channel)
        Router->>Router: compute recipients (mentions, response_mode, muted, DM)
        loop per candidate agent
            Router->>Router: compute next_hops
            alt next_hops > max_hops && not forced
                Router-->>Router: skip agent
            else
                Router->>Policy: try_acquire(channel_id, agent, settings)
                alt denied
                    Router-->>Router: skip agent
                else allowed
                    Router->>Context: build_context_window(recent_messages)
                    Context-->>Router: context
                    Router->>Bridge: enqueue_user_message(agent, context, force_respond, hops)
                    Bridge->>Agent: deliver message
                    Agent-->>Bridge: reply stream
                    Bridge->>DB: persist reply
                    Bridge->>Router: dispatch(persisted_reply, channel) (re-dispatch)
                    Router->>Policy: record_send(channel_id, agent)
                end
            end
        end
        API-->>User: HTTP 200/201
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I nudged the slashes gently, sent them hopping free,

Mentions now decide who peeks and who sits quietly.
Hops and cool-downs patrol the field, context grows like thyme,
Reactions call for echoes, bridges stitch reply-time.
A rabbit cheers the changes — nimble, tidy, sublime!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: multi-agent routing, mentions parsing, policy enforcement, reaction handling, and slash-command guardrail—all core features in this substantial Phase 1 implementation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/chat-phase-1-conversational-core

Comment @coderabbitai help to get the list of available commands and usage tips.

@kilo-code-bot
Copy link
Copy Markdown

kilo-code-bot bot commented Apr 19, 2026

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Incremental Review Note: Reviewed changes since fd70ab8 — minor optimizations and bug fixes in agent routing and reactions.

Files Reviewed (30 files)
  • docs/superpowers/specs/2026-04-19-chat-phase-1-conversational-core-design.md
  • pyproject.toml
  • tests/e2e/test_roundtable_group_chat.py
  • tests/test_agent_chat_router.py
  • tests/test_bridge_session_phase1.py
  • tests/test_chat_channel_settings.py
  • tests/test_chat_channels.py
  • tests/test_chat_context_window.py
  • tests/test_chat_group_policy.py
  • tests/test_chat_mentions.py
  • tests/test_chat_messages.py
  • tests/test_chat_reactions.py
  • tests/test_routes_chat_admin.py
  • tests/test_routes_chat_slash_guard.py
  • tinyagentos/agent_chat_router.py
  • tinyagentos/app.py
  • tinyagentos/bridge_session.py
  • tinyagentos/chat/channel_store.py
  • tinyagentos/chat/context_window.py
  • tinyagentos/chat/group_policy.py
  • tinyagentos/chat/mentions.py
  • tinyagentos/chat/message_store.py
  • tinyagentos/chat/reactions.py
  • tinyagentos/routes/chat.py
  • tinyagentos/scripts/install_hermes.sh
  • tinyagentos/scripts/install_langroid.sh
  • tinyagentos/scripts/install_openai-agents-sdk.sh
  • tinyagentos/scripts/install_openai_agents_sdk.sh
  • tinyagentos/scripts/install_pocketflow.sh
  • tinyagentos/scripts/install_smolagents.sh

Reviewed by grok-code-fast-1:optimized:free · 515,378 tokens

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
tinyagentos/scripts/install_openai-agents-sdk.sh (1)

67-69: ⚠️ Potential issue | 🟠 Major

channel_id is passed but never sent in the reply payload.

Line 79 forwards channel_id, but post_reply() ignores cid, so the new channel-aware reply behavior is not actually applied.

🐛 Suggested fix
 async def post_reply(c, u, t, mid, tid, txt, cid=None):
-    try: await c.post(u, json={"kind":"final","id":mid,"trace_id":tid,"content":txt}, headers={"Authorization":f"Bearer {t}"}, timeout=30)
+    payload = {"kind":"final","id":mid,"trace_id":tid,"content":txt}
+    if cid:
+        payload["channel_id"] = cid
+    try: await c.post(u, json=payload, headers={"Authorization":f"Bearer {t}"}, timeout=30)
     except Exception as e: log.warning("reply: %s", e)

Also applies to: 79-79

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/scripts/install_openai-agents-sdk.sh` around lines 67 - 69, The
post_reply function is ignoring the cid parameter so channel_id never gets sent;
update async def post_reply(c, u, t, mid, tid, txt, cid=None) to include the
channel id in the JSON payload (e.g. add "channel_id": cid) when calling c.post
(or omit it only if cid is None) so that the request body contains kind, id,
trace_id, content and channel_id and the channel-aware reply behavior actually
takes effect.
tinyagentos/scripts/install_openai_agents_sdk.sh (1)

67-79: ⚠️ Potential issue | 🟡 Minor

Align with the install_smolagents.sh pattern: conditionally add channel_id to the reply body.

While the endpoint includes a fallback mechanism to recover channel_id from the original message via trace_id, this bridge doesn't proactively send it in the payload. This causes unnecessary database lookups and creates inconsistency with other bridges—install_smolagents.sh and install_hermes.sh correctly include if cid: body["channel_id"] = cid. Update lines 67–68 to match that pattern.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/scripts/install_openai_agents_sdk.sh` around lines 67 - 79, The
post_reply/handle flow currently always sends a fixed JSON body and doesn't
include channel_id, causing extra lookups; update the post_reply logic (used by
async def post_reply and its caller async def handle) to build the request body
conditionally and add body["channel_id"] = cid when cid is truthy (mirror the
install_smolagents.sh pattern), then send that body in the POST; ensure you
preserve the existing Authorization header and timeout behavior and keep the
same parameter order for post_reply(mid, tid, txt, cid) usage.
tinyagentos/bridge_session.py (1)

318-368: ⚠️ Potential issue | 🟠 Major

Streaming replies never get re-dispatched.

persisted is only assigned in the no-placeholder branch. If a bridge sends delta then final, this code edits the existing streaming message but leaves persisted as None, so router.dispatch(...) is skipped. In practice that means other agents still won't see the common streamed-reply path.

Suggested fix
             persisted = None
             if channel_id:
                 if pending_msg_id and self._chat_messages:
                     # Edit the streaming placeholder to final content.
                     await self._chat_messages.edit_message(pending_msg_id, final_content)
                     await self._chat_messages.update_state(pending_msg_id, "complete")
                     session.pending_hops.pop(trace_id, None)
+                    persisted = await self._chat_messages.get_message(pending_msg_id)
                     if self._chat_hub:
                         await self._chat_hub.broadcast(channel_id, {
                             "type": "message_edit",
                             "seq": self._chat_hub.next_seq(),
                             "message_id": pending_msg_id,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/bridge_session.py` around lines 318 - 368, The edited streaming
placeholder path currently never sets persisted so router.dispatch is skipped;
after the edit branch (where pending_msg_id is truthy and you call await
self._chat_messages.edit_message(pending_msg_id, final_content) and
update_state), set persisted to the updated message object so re-dispatch runs —
either capture the return value of self._chat_messages.edit_message (if it
returns the message) or fetch/construct a minimal persisted dict containing at
least "message_id": pending_msg_id and "channel_id": channel_id plus any needed
metadata (e.g., trace_id, openclaw_msg_id, hops) before calling router.dispatch;
ensure you still pop session.pending_hops(trace_id) as currently done.
🧹 Nitpick comments (10)
tests/test_chat_messages.py (1)

219-240: Close ChatMessageStore in these tests to avoid resource leakage.

Both tests initialize a DB-backed store but never close it. Please wrap body in try/finally (or use a fixture) so cleanup always runs.

♻️ Suggested test cleanup pattern
 async def test_send_message_persists_hops_metadata(tmp_path):
     store = ChatMessageStore(tmp_path / "msgs.db")
     await store.init()
-    msg = await store.send_message(
-        channel_id="c1", author_id="tom", author_type="agent",
-        content="yo", content_type="text", state="complete",
-        metadata={"hops_since_user": 2, "other": "x"},
-    )
-    assert msg["metadata"]["hops_since_user"] == 2
-    assert msg["metadata"]["other"] == "x"
+    try:
+        msg = await store.send_message(
+            channel_id="c1", author_id="tom", author_type="agent",
+            content="yo", content_type="text", state="complete",
+            metadata={"hops_since_user": 2, "other": "x"},
+        )
+        assert msg["metadata"]["hops_since_user"] == 2
+        assert msg["metadata"]["other"] == "x"
+    finally:
+        await store.close()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_chat_messages.py` around lines 219 - 240, Both tests open a
ChatMessageStore but never close it; wrap the test bodies of
test_send_message_persists_hops_metadata and
test_send_message_defaults_hops_zero_when_absent in a try/finally (or use an
async fixture/context manager) and call await store.close() in the finally block
to ensure the DB-backed ChatMessageStore is always closed and resources are
released.
tests/test_chat_channel_settings.py (1)

6-47: Use guaranteed teardown for ChatChannelStore in these tests.

await store.close() is only reached if assertions pass; switching to a fixture or try/finally will make cleanup deterministic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_chat_channel_settings.py` around lines 6 - 47, The tests create a
ChatChannelStore instance and call await store.close() only on the success path;
wrap each test body that creates ChatChannelStore (e.g.,
test_get_channel_backfills_phase1_defaults, test_set_response_mode_persists,
test_mute_and_unmute_agent) in a try/finally so await store.close() is executed
in the finally block, or convert the store creation to a pytest async fixture
that yields the store and calls await store.close() after yield; reference
ChatChannelStore, store.init(), and store.close() when updating the tests.
tests/test_agent_chat_router.py (1)

163-164: Consider moving GroupPolicy import to module level.

GroupPolicy is imported locally in each test function. Moving it to the module-level imports would reduce repetition.

♻️ Proposed refactor
 from tinyagentos.agent_chat_router import AgentChatRouter
+from tinyagentos.chat.group_policy import GroupPolicy

Then remove the local imports from each test function.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_agent_chat_router.py` around lines 163 - 164, Move the local
import of GroupPolicy to the module-level imports so tests reuse a single import
instead of importing inside each test function; update the top-of-file import
block to include "from tinyagentos.chat.group_policy import GroupPolicy" and
remove the in-test local imports and the subsequent "state.group_policy =
GroupPolicy()" initializations inside individual tests so they instead reference
the module-level GroupPolicy (ensure any test-specific setup still sets
state.group_policy where needed).
tinyagentos/routes/chat.py (3)

257-258: Consider logging archive failures at debug level.

The try-except-pass (S110) is intentional per the comment, but logging the exception would aid debugging without blocking the chat flow.

♻️ Proposed fix
         except Exception:
-            pass  # Never block chat for archive failures
+            logger.debug("archive recording failed", exc_info=True)  # Never block chat
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/routes/chat.py` around lines 257 - 258, The blind except in the
chat archive block (the "except Exception: pass  # Never block chat for archive
failures" stanza) should log the failure at debug level without changing control
flow; replace the bare pass with a debug log that includes the exception details
(e.g., logger.debug("Chat archive failed, continuing chat", exc_info=True) or
app.logger.debug(..., exc_info=True)) so archive errors are recorded for
debugging but still do not block chat.

396-399: Non-integer values for max_hops and cooldown_seconds will raise ValueError.

If the request body contains a non-integer value like {"max_hops": "abc"}, int() will raise ValueError which is caught and returned as 400. This is acceptable behavior, but the error message will be Python's default (invalid literal for int()...) rather than a user-friendly message.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/routes/chat.py` around lines 396 - 399, The route currently calls
int(body["max_hops"]) and int(body["cooldown_seconds"]) before awaiting
chs.set_max_hops and chs.set_cooldown_seconds which will raise a raw ValueError
for non-integer input; update the handler to validate/parse these fields first
(e.g., try/except around int(...) or use a helper parse_int) and on parse
failure raise/return a 400 with a clear user-friendly message like "max_hops
must be an integer" or "cooldown_seconds must be an integer", referencing the
existing calls to chs.set_max_hops and chs.set_cooldown_seconds so the validated
integer is passed into those methods.

437-454: Muted endpoint doesn't validate slug against known agents.

Unlike the members endpoint (line 428-430), the muted endpoint allows adding any slug to the muted list without checking if it's a known agent. This could lead to typos going unnoticed.

♻️ Proposed fix to add validation
     if action == "add":
+        known = {a.get("name") for a in getattr(state.config, "agents", []) or []}
+        if slug not in known:
+            return JSONResponse({"error": f"unknown agent: {slug}"}, status_code=400)
         await chs.mute_agent(channel_id, slug)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/routes/chat.py` around lines 437 - 454, The modify_channel_muted
handler accepts any slug and can add unknown/typoed agents to muted; before
calling chs.mute_agent or chs.unmute_agent you must validate the slug exists
using the same check the members endpoint uses (e.g., call the agents lookup
used elsewhere like state.agents.get_agent(slug) or the members validation
logic) and return a 400 error if the agent is not found; keep the rest of
modify_channel_muted (action parsing and channel existence check) unchanged and
only perform this existence check immediately after computing slug and before
invoking chs.mute_agent/ch.sunmute_agent.
tests/test_routes_chat_admin.py (2)

103-110: Prefix unused variable with underscore.

The app variable is unpacked but never used in this test. Prefix it with _ to satisfy the linter.

♻️ Proposed fix
 `@pytest.mark.asyncio`
 async def test_patch_unknown_channel_returns_404(tmp_path):
-    app, client = await _setup_client(tmp_path)
+    _app, client = await _setup_client(tmp_path)
     async with client:
         r = await client.patch(
             "/api/chat/channels/doesnotexist",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_routes_chat_admin.py` around lines 103 - 110, In
test_patch_unknown_channel_returns_404, the unpacked app variable from the call
to _setup_client is unused; rename it to _app (or simply _ ) so the test reads "
_app, client = await _setup_client(tmp_path)" to satisfy the linter while
keeping the rest of the test (client usage and assertions) unchanged; update the
assignment in the test_patch_unknown_channel_returns_404 function where
_setup_client is called.

156-169: Consider adding test for muted remove action.

There's a test for action: add to muted but not for action: remove. Consider adding coverage for unmuting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_routes_chat_admin.py` around lines 156 - 169, Add a new async test
mirroring test_muted_add_agent that verifies the "remove" action: use
_setup_client and _make_channel to get app and ch_id, POST to
"/api/chat/channels/{ch_id}/muted" with json {"action": "remove", "slug": "tom"}
(ensure "tom" is present first by either adding him via the same endpoint or
pre-populating), assert r.status_code == 200, then call
app.state.chat_channels.get_channel(ch_id) and assert "tom" is not in
ch["settings"]["muted"]; name the test e.g. test_muted_remove_agent to match the
existing pattern.
tests/test_routes_chat_slash_guard.py (1)

37-53: Consider adding edge case for whitespace-prefixed slash commands.

The guardrail in routes/chat.py uses content.lstrip().startswith("/"), so a message like " /help" would also be rejected. Consider adding a test to verify this behavior is intentional.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_routes_chat_slash_guard.py` around lines 37 - 53, Add a new async
test mirroring test_bare_slash_in_group_returns_400 that sends a message with
leading whitespace (e.g. "  /help") to ensure routes/chat.py's guard (which
checks content.lstrip().startswith("/")) rejects whitespace-prefixed slash
commands; reuse the same channel creation via
app.state.chat_channels.create_channel and a POST to "/api/chat/messages",
assert a 400 status and that the error contains "address an agent"; name the
test something like test_whitespace_prefixed_slash_in_group_returns_400 to make
the intent clear.
tinyagentos/agent_chat_router.py (1)

46-47: Redundant system message check.

This check duplicates the filter at line 30-31 in dispatch(). It's harmless but unnecessary since _route_inner is only called via _route which is only invoked from dispatch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/agent_chat_router.py` around lines 46 - 47, Remove the redundant
system message guard inside _route_inner: the check `if
message.get("content_type") == "system": return` duplicates the filtering
already performed in dispatch() (and _route) so delete that conditional from
_route_inner (confirm no other external callers of _route_inner rely on it),
then run tests to ensure behavior is unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@docs/superpowers/specs/2026-04-19-chat-phase-1-conversational-core-design.md`:
- Around line 324-342: The doc is inconsistent: the design now states taOS does
not intercept `/` but earlier sections still reference slash_commands.execute
and command-specific error handling/testing; update the spec by removing or
rewriting any prior references to the slash_commands.execute branch, related
command-specific error handling/testing, and any tests or examples that assume
taOS intercepts `/` so they reflect the resolved design (UI-driven REST
endpoints and the guardrail requiring `@<slug>`/`@all`), and ensure any symbol
names like slash_commands.execute, slash command error handlers, and command
testing examples are either removed or altered to describe the UI/REST-driven
flow and guardrail behavior.
- Around line 384-385: Update the modified-files summary row so it correctly
points the "re-dispatch on agent reply" behavior to
tinyagentos/bridge_session.py (where router.dispatch is called on agent replies
and message_suppressed is traced) instead of tinyagentos/routes/chat.py; remove
or relocate that phrase from the routes/chat.py row and ensure the
bridge_session.py row mentions accepting force_respond, context, hops_since_user
and calling router.dispatch on agent replies to match the implementation.

In `@tests/e2e/test_roundtable_group_chat.py`:
- Around line 42-55: The poll currently inspects the channel's last 20 messages
unscoped to this test; capture the message id returned when posting the prompt
(e.g., sent_msg_id) and restrict the polling logic to replies tied to that id by
filtering messages where m.get("parent_id") == sent_msg_id (or the equivalent
reply/root field used by the API) before computing agent_msgs, names and
cross_refs; update the list comprehension that builds agent_msgs and the
cross_refs calculation to only consider messages with that parent/root matching
sent_msg_id so older messages in the channel cannot satisfy the success
condition.
- Around line 25-31: The test currently posts a bare "/lively" message via
c.post("/api/chat/messages") for CHANNEL_ID which is rejected by the bare-slash
guardrail; replace that step with a setup call to the admin REST API to put the
channel into lively mode for CHANNEL_ID (using the test client c.post to the
admin endpoint) before exercising the roundtable flow, or if you prefer the
in-channel route change the message content to address an agent (e.g., "/lively
`@assistant`") so the guardrail accepts it; update the code that currently posts
"/lively" to use one of these two fixes (referencing CHANNEL_ID and the c.post
call in the diff).

In `@tests/test_bridge_session_phase1.py`:
- Around line 38-40: Split the semicolon-separated setup into separate
statements so the linter won't flag E702: create each mock assignment on its own
line (e.g., assign chans = MagicMock(), then set chans.update_last_message_at =
AsyncMock(); assign hub = MagicMock(), then set hub.broadcast = AsyncMock() and
hub.next_seq = MagicMock(return_value=1); finally instantiate reg =
BridgeSessionRegistry(chat_messages=store, chat_channels=chans, chat_hub=hub)).
Ensure you reference the existing symbols chans, hub, AsyncMock, MagicMock, and
BridgeSessionRegistry exactly as used in the diff.

In `@tests/test_chat_reactions.py`:
- Around line 9-10: The test sets multiple statements on one line causing E702;
split the mock setup into separate statements so each assignment is on its own
line: create bridge = MagicMock() on its own line, then set
bridge.enqueue_user_message = AsyncMock() on the next line, and then create
state = MagicMock(bridge_sessions=bridge) on its own line (apply the same fix
for the similar lines around where bridge and state are set at lines 29-30).

In `@tinyagentos/chat/context_window.py`:
- Around line 11-14: The estimate_tokens function can return 0 for short
non-empty strings, letting build_context_window include messages for free;
change estimate_tokens (referenced by build_context_window) so any non-empty
text returns at least 1 token (e.g., return max(1, len(text) // 4)) so 1–3
character messages are counted; keep the early empty check and apply the clamp
before returning.

In `@tinyagentos/chat/group_policy.py`:
- Around line 21-42: may_send()/record_send() are racy because callers can await
between them; implement an atomic acquire method (e.g.,
try_acquire_send(channel_id: str, agent: str, settings: dict) -> bool) that
checks cooldown and rate cap and, if allowed, immediately updates _last_send_at
and _recent_sends in one locked operation; add a Lock (or use existing
synchronization) inside the class and use it in try_acquire_send to guard
reads/writes of _last_send_at and _recent_sends, keep may_send/record_send for
backwards compatibility or deprecate them and update call sites to use
try_acquire_send.

In `@tinyagentos/chat/reactions.py`:
- Around line 40-52: The regenerate branch currently hard-codes "context": []
when calling bridge.enqueue_user_message, which drops room history; replace that
empty context with the same rolling context used for normal agent turns (i.e.,
build and pass the conversation window instead of an empty list) or route this
regenerate event through the existing fanout/context path that constructs the
context window; update the call in reactions.py (the dict passed to
bridge.enqueue_user_message where "context": [] and "regenerate": True are set,
and using reactor_id/message) to supply the reconstructed context window (or
call the fanout/context helper used elsewhere) so regenerated messages receive
the same recent history as regular agent turns.

In `@tinyagentos/scripts/install_langroid.sh`:
- Around line 60-72: post_reply currently ignores the cid parameter, so include
channel routing by adding channel_id to the JSON payload when cid is present:
update post_reply(c, u, t, mid, tid, txt, cid=None) to build body =
{"kind":"final","id":mid,"trace_id":tid,"content":txt} and if cid:
body["channel_id"]=cid, then send that body in the POST; keep handle() as-is (it
passes evt.get("channel_id")), and ensure log/warning behavior and timeout
remain unchanged.

---

Outside diff comments:
In `@tinyagentos/bridge_session.py`:
- Around line 318-368: The edited streaming placeholder path currently never
sets persisted so router.dispatch is skipped; after the edit branch (where
pending_msg_id is truthy and you call await
self._chat_messages.edit_message(pending_msg_id, final_content) and
update_state), set persisted to the updated message object so re-dispatch runs —
either capture the return value of self._chat_messages.edit_message (if it
returns the message) or fetch/construct a minimal persisted dict containing at
least "message_id": pending_msg_id and "channel_id": channel_id plus any needed
metadata (e.g., trace_id, openclaw_msg_id, hops) before calling router.dispatch;
ensure you still pop session.pending_hops(trace_id) as currently done.

In `@tinyagentos/scripts/install_openai_agents_sdk.sh`:
- Around line 67-79: The post_reply/handle flow currently always sends a fixed
JSON body and doesn't include channel_id, causing extra lookups; update the
post_reply logic (used by async def post_reply and its caller async def handle)
to build the request body conditionally and add body["channel_id"] = cid when
cid is truthy (mirror the install_smolagents.sh pattern), then send that body in
the POST; ensure you preserve the existing Authorization header and timeout
behavior and keep the same parameter order for post_reply(mid, tid, txt, cid)
usage.

In `@tinyagentos/scripts/install_openai-agents-sdk.sh`:
- Around line 67-69: The post_reply function is ignoring the cid parameter so
channel_id never gets sent; update async def post_reply(c, u, t, mid, tid, txt,
cid=None) to include the channel id in the JSON payload (e.g. add "channel_id":
cid) when calling c.post (or omit it only if cid is None) so that the request
body contains kind, id, trace_id, content and channel_id and the channel-aware
reply behavior actually takes effect.

---

Nitpick comments:
In `@tests/test_agent_chat_router.py`:
- Around line 163-164: Move the local import of GroupPolicy to the module-level
imports so tests reuse a single import instead of importing inside each test
function; update the top-of-file import block to include "from
tinyagentos.chat.group_policy import GroupPolicy" and remove the in-test local
imports and the subsequent "state.group_policy = GroupPolicy()" initializations
inside individual tests so they instead reference the module-level GroupPolicy
(ensure any test-specific setup still sets state.group_policy where needed).

In `@tests/test_chat_channel_settings.py`:
- Around line 6-47: The tests create a ChatChannelStore instance and call await
store.close() only on the success path; wrap each test body that creates
ChatChannelStore (e.g., test_get_channel_backfills_phase1_defaults,
test_set_response_mode_persists, test_mute_and_unmute_agent) in a try/finally so
await store.close() is executed in the finally block, or convert the store
creation to a pytest async fixture that yields the store and calls await
store.close() after yield; reference ChatChannelStore, store.init(), and
store.close() when updating the tests.

In `@tests/test_chat_messages.py`:
- Around line 219-240: Both tests open a ChatMessageStore but never close it;
wrap the test bodies of test_send_message_persists_hops_metadata and
test_send_message_defaults_hops_zero_when_absent in a try/finally (or use an
async fixture/context manager) and call await store.close() in the finally block
to ensure the DB-backed ChatMessageStore is always closed and resources are
released.

In `@tests/test_routes_chat_admin.py`:
- Around line 103-110: In test_patch_unknown_channel_returns_404, the unpacked
app variable from the call to _setup_client is unused; rename it to _app (or
simply _ ) so the test reads " _app, client = await _setup_client(tmp_path)" to
satisfy the linter while keeping the rest of the test (client usage and
assertions) unchanged; update the assignment in the
test_patch_unknown_channel_returns_404 function where _setup_client is called.
- Around line 156-169: Add a new async test mirroring test_muted_add_agent that
verifies the "remove" action: use _setup_client and _make_channel to get app and
ch_id, POST to "/api/chat/channels/{ch_id}/muted" with json {"action": "remove",
"slug": "tom"} (ensure "tom" is present first by either adding him via the same
endpoint or pre-populating), assert r.status_code == 200, then call
app.state.chat_channels.get_channel(ch_id) and assert "tom" is not in
ch["settings"]["muted"]; name the test e.g. test_muted_remove_agent to match the
existing pattern.

In `@tests/test_routes_chat_slash_guard.py`:
- Around line 37-53: Add a new async test mirroring
test_bare_slash_in_group_returns_400 that sends a message with leading
whitespace (e.g. "  /help") to ensure routes/chat.py's guard (which checks
content.lstrip().startswith("/")) rejects whitespace-prefixed slash commands;
reuse the same channel creation via app.state.chat_channels.create_channel and a
POST to "/api/chat/messages", assert a 400 status and that the error contains
"address an agent"; name the test something like
test_whitespace_prefixed_slash_in_group_returns_400 to make the intent clear.

In `@tinyagentos/agent_chat_router.py`:
- Around line 46-47: Remove the redundant system message guard inside
_route_inner: the check `if message.get("content_type") == "system": return`
duplicates the filtering already performed in dispatch() (and _route) so delete
that conditional from _route_inner (confirm no other external callers of
_route_inner rely on it), then run tests to ensure behavior is unchanged.

In `@tinyagentos/routes/chat.py`:
- Around line 257-258: The blind except in the chat archive block (the "except
Exception: pass  # Never block chat for archive failures" stanza) should log the
failure at debug level without changing control flow; replace the bare pass with
a debug log that includes the exception details (e.g., logger.debug("Chat
archive failed, continuing chat", exc_info=True) or app.logger.debug(...,
exc_info=True)) so archive errors are recorded for debugging but still do not
block chat.
- Around line 396-399: The route currently calls int(body["max_hops"]) and
int(body["cooldown_seconds"]) before awaiting chs.set_max_hops and
chs.set_cooldown_seconds which will raise a raw ValueError for non-integer
input; update the handler to validate/parse these fields first (e.g., try/except
around int(...) or use a helper parse_int) and on parse failure raise/return a
400 with a clear user-friendly message like "max_hops must be an integer" or
"cooldown_seconds must be an integer", referencing the existing calls to
chs.set_max_hops and chs.set_cooldown_seconds so the validated integer is passed
into those methods.
- Around line 437-454: The modify_channel_muted handler accepts any slug and can
add unknown/typoed agents to muted; before calling chs.mute_agent or
chs.unmute_agent you must validate the slug exists using the same check the
members endpoint uses (e.g., call the agents lookup used elsewhere like
state.agents.get_agent(slug) or the members validation logic) and return a 400
error if the agent is not found; keep the rest of modify_channel_muted (action
parsing and channel existence check) unchanged and only perform this existence
check immediately after computing slug and before invoking
chs.mute_agent/ch.sunmute_agent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 503bc2e6-61be-4576-b064-481c9cb9f5ac

📥 Commits

Reviewing files that changed from the base of the PR and between b90b56d and 8ccab65.

📒 Files selected for processing (30)
  • docs/superpowers/specs/2026-04-19-chat-phase-1-conversational-core-design.md
  • pyproject.toml
  • tests/e2e/test_roundtable_group_chat.py
  • tests/test_agent_chat_router.py
  • tests/test_bridge_session_phase1.py
  • tests/test_chat_channel_settings.py
  • tests/test_chat_channels.py
  • tests/test_chat_context_window.py
  • tests/test_chat_group_policy.py
  • tests/test_chat_mentions.py
  • tests/test_chat_messages.py
  • tests/test_chat_reactions.py
  • tests/test_routes_chat_admin.py
  • tests/test_routes_chat_slash_guard.py
  • tinyagentos/agent_chat_router.py
  • tinyagentos/app.py
  • tinyagentos/bridge_session.py
  • tinyagentos/chat/channel_store.py
  • tinyagentos/chat/context_window.py
  • tinyagentos/chat/group_policy.py
  • tinyagentos/chat/mentions.py
  • tinyagentos/chat/message_store.py
  • tinyagentos/chat/reactions.py
  • tinyagentos/routes/chat.py
  • tinyagentos/scripts/install_hermes.sh
  • tinyagentos/scripts/install_langroid.sh
  • tinyagentos/scripts/install_openai-agents-sdk.sh
  • tinyagentos/scripts/install_openai_agents_sdk.sh
  • tinyagentos/scripts/install_pocketflow.sh
  • tinyagentos/scripts/install_smolagents.sh

Comment thread docs/superpowers/specs/2026-04-19-chat-phase-1-conversational-core-design.md Outdated
Comment thread tests/e2e/test_roundtable_group_chat.py Outdated
Comment thread tests/e2e/test_roundtable_group_chat.py Outdated
Comment thread tests/test_bridge_session_phase1.py Outdated
Comment thread tests/test_chat_reactions.py Outdated
Comment thread tinyagentos/chat/context_window.py Outdated
Comment on lines +21 to +42
def may_send(self, channel_id: str, agent: str, settings: dict) -> bool:
now = _now()
cooldown = int(settings.get("cooldown_seconds", 5))
cap = int(settings.get("rate_cap_per_minute", 20))

last = self._last_send_at.get((channel_id, agent))
if last is not None and (now - last) < cooldown:
return False

window = self._recent_sends.get(channel_id)
if window:
while window and (now - window[0]) > 60.0:
window.popleft()
if len(window) >= cap:
return False
return True

def record_send(self, channel_id: str, agent: str) -> None:
now = _now()
self._last_send_at[(channel_id, agent)] = now
window = self._recent_sends.setdefault(channel_id, deque(maxlen=256))
window.append(now)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Policy enforcement is non-atomic across may_send()/record_send().

Because callers await between these calls, concurrent routes can all pass may_send() before any record_send() occurs, weakening cooldown/rate caps.

🐛 Suggested direction (atomic acquire)
 class GroupPolicy:
@@
-    def may_send(self, channel_id: str, agent: str, settings: dict) -> bool:
+    def try_acquire_send(self, channel_id: str, agent: str, settings: dict) -> bool:
         now = _now()
         cooldown = int(settings.get("cooldown_seconds", 5))
         cap = int(settings.get("rate_cap_per_minute", 20))
@@
-        return True
-
-    def record_send(self, channel_id: str, agent: str) -> None:
-        now = _now()
-        self._last_send_at[(channel_id, agent)] = now
-        window = self._recent_sends.setdefault(channel_id, deque(maxlen=256))
-        window.append(now)
+        self._last_send_at[(channel_id, agent)] = now
+        window = self._recent_sends.setdefault(channel_id, deque(maxlen=256))
+        window.append(now)
+        return True

Then call try_acquire_send(...) before enqueue, instead of separate check/record calls.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def may_send(self, channel_id: str, agent: str, settings: dict) -> bool:
now = _now()
cooldown = int(settings.get("cooldown_seconds", 5))
cap = int(settings.get("rate_cap_per_minute", 20))
last = self._last_send_at.get((channel_id, agent))
if last is not None and (now - last) < cooldown:
return False
window = self._recent_sends.get(channel_id)
if window:
while window and (now - window[0]) > 60.0:
window.popleft()
if len(window) >= cap:
return False
return True
def record_send(self, channel_id: str, agent: str) -> None:
now = _now()
self._last_send_at[(channel_id, agent)] = now
window = self._recent_sends.setdefault(channel_id, deque(maxlen=256))
window.append(now)
def try_acquire_send(self, channel_id: str, agent: str, settings: dict) -> bool:
now = _now()
cooldown = int(settings.get("cooldown_seconds", 5))
cap = int(settings.get("rate_cap_per_minute", 20))
last = self._last_send_at.get((channel_id, agent))
if last is not None and (now - last) < cooldown:
return False
window = self._recent_sends.get(channel_id)
if window:
while window and (now - window[0]) > 60.0:
window.popleft()
if len(window) >= cap:
return False
self._last_send_at[(channel_id, agent)] = now
window = self._recent_sends.setdefault(channel_id, deque(maxlen=256))
window.append(now)
return True
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/chat/group_policy.py` around lines 21 - 42,
may_send()/record_send() are racy because callers can await between them;
implement an atomic acquire method (e.g., try_acquire_send(channel_id: str,
agent: str, settings: dict) -> bool) that checks cooldown and rate cap and, if
allowed, immediately updates _last_send_at and _recent_sends in one locked
operation; add a Lock (or use existing synchronization) inside the class and use
it in try_acquire_send to guard reads/writes of _last_send_at and _recent_sends,
keep may_send/record_send for backwards compatibility or deprecate them and
update call sites to use try_acquire_send.

Comment thread tinyagentos/chat/reactions.py
Comment thread tinyagentos/scripts/install_langroid.sh
jaylfc added 3 commits April 19, 2026 14:19
…flow/openai-agents

The smolagents and hermes bridges already build the reply body and
conditionally include channel_id; the other four dropped it, relying on
the host-side trace_id fallback in bridge_session._handle_reply. That
fallback still works but is defense in depth, not the primary path.
Threading channel_id through keeps room affinity explicit and aligns
all six bridges on one pattern.
- group_policy: add try_acquire() to atomically check+record in one
  non-awaiting call, so concurrent asyncio routes can't both pass when
  only one should; router switches to try_acquire.
- reactions: regenerate path now rebuilds a context window from chat
  messages (was hard-coded []), so 👎 retries see the same room history
  as regular turns.
- e2e: switch /lively setup to PATCH admin endpoint (bare-slash guard
  now rejects it); scope the pass condition to replies correlated to
  this test's message id (reused e2e channel can't satisfy it with
  stale agent messages).
- Split chained `;`-separated statements in two test files (Ruff E702).
- estimate_tokens returns 1 for 1-3 char inputs so the token budget
  can't silently be exceeded by many short non-empty messages.
- Spec doc: remove leftover slash_commands.execute references and fix
  the row that claimed re-dispatch is in routes/chat.py (it's in
  bridge_session.py); spec file is gitignored (docs/superpowers/specs).
@jaylfc
Copy link
Copy Markdown
Owner Author

jaylfc commented Apr 19, 2026

Addressed all 10 CodeRabbit findings in 4 follow-up commits:

  • ca3a498Critical bridge bug: 4 bridges (langroid, pocketflow, openai-agents-sdk, openai_agents_sdk) dropped channel_id from their reply payload. Smolagents + hermes were already correct — brought the other four onto the same pattern.
  • bc21386Major fixes: (1) GroupPolicy.try_acquire() combines check+record atomically so concurrent asyncio routes can't both pass when only one should; router switched to it. (2) Reactions 👎 regenerate path now rebuilds a real context window instead of hard-coding []. (3) E2E roundtable test uses PATCH admin endpoint instead of /lively (which the new guardrail rejects) and correlates replies by metadata.trace_id to the sent message id (was allowing stale channel messages to trivially satisfy the pass condition).
  • 923708eMinor cleanups: split chained ;-separated statements (Ruff E702), estimate_tokens clamps non-empty text to ≥1 token so the budget can't be silently exceeded by many short messages.
  • fd70ab8Spec cleanup: removed remaining slash_commands.execute references, fixed the error-handling section to describe the bare-slash guardrail + admin-endpoint errors, and clarified the "system messages" parenthetical (no longer just slash-command echoes).

Full suite: 1908 passed / 13 skipped. The 3 test_hardware.py failures are pre-existing macOS arch-naming issues (arm64 vs aarch64) unrelated to this branch.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tinyagentos/agent_chat_router.py (1)

92-113: ⚠️ Potential issue | 🟠 Major

Move try_acquire() after the deliverability checks.

Line 97 records the send immediately, but find_agent(), the running-status check, and the bridge-registry check all happen afterwards. In lively channels, a stopped/misconfigured agent can therefore burn cooldown slots and the channel-wide rate cap without any enqueue_user_message() ever happening. Acquire the policy slot only once you know the recipient is actually sendable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/agent_chat_router.py` around lines 92 - 113, The
policy.try_acquire() call is happening too early and can consume rate/cooldown
slots for agents that are later found undeliverable; move the
policy.try_acquire(channel["id"], agent_name, settings) invocation so it runs
only after we confirm the recipient is deliverable (i.e., after
find_agent(config, agent_name) returns non-None, after the agent.get("status")
== "running" check, and after the bridge != None check), keep honoring the
forced = force_by_slug.get(agent_name, False) bypass (do not call try_acquire if
forced is True), and leave the next_hops > max_hops short-circuit where it is.
🧹 Nitpick comments (1)
tinyagentos/scripts/install_openai-agents-sdk.sh (1)

31-81: Extract the shared bridge scaffold before it drifts again.

This block now mirrors tinyagentos/scripts/install_langroid.sh almost verbatim for context rendering, NO_RESPONSE suppression, reply posting, and handler flow. Keeping that logic copy-pasted across installers is likely to recreate the same cross-bridge skew this PR just cleaned up. Consider moving the common Python scaffold into one shared module/template and leaving only the framework-specific _build/_run pieces per bridge.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/scripts/install_openai-agents-sdk.sh` around lines 31 - 81, The
reviewer wants the duplicated bridge scaffold (context rendering, NO_RESPONSE
suppression, posting, and handler flow) extracted into a shared module/template
rather than copied into each installer; refactor the common functions
_render_context, _suppress, post_reply, and handle into a single shared utility
(or template) and import/use them from
tinyagentos/scripts/install_openai-agents-sdk.sh, leaving only bridge-specific
pieces (_build and _run) in this file; ensure the shared module exposes the same
function signatures (context list input, suppression semantics, post_reply
signature, and an async handler wrapper) so you can call the shared handler with
the bridge-specific _build/_run implementations and maintain current behavior
and logging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tinyagentos/agent_chat_router.py`:
- Around line 115-125: The context-building (calls to
self._state.chat_messages.get_messages and build_context_window) should be
executed once per routed message instead of inside the recipient loop: move the
block that sets context (including the try/except with logger.warning) out of
the per-recipient loop so you compute context once for the channel (use
channel.get("id") or channel["id"] as the key) and then reuse that same context
variable when enqueuing for each recipient; preserve the existing error handling
and fallback to an empty list on failure.
- Around line 84-85: The code reads metadata.hops_since_user into current_hops
and adds 1 without validation; coerce/validate that value to an integer
(defaulting to 0 for None, non-numeric strings, or other invalid types) before
computing next_hops so a malicious or malformed message won't raise; update the
logic around current_hops and next_hops in agent_chat_router.py to parse
int(message.get("metadata", {}).get("hops_since_user", 0)) with a safe fallback
(e.g., try/except or isinstance checks) and then set next_hops =
validated_current_hops + 1.

---

Outside diff comments:
In `@tinyagentos/agent_chat_router.py`:
- Around line 92-113: The policy.try_acquire() call is happening too early and
can consume rate/cooldown slots for agents that are later found undeliverable;
move the policy.try_acquire(channel["id"], agent_name, settings) invocation so
it runs only after we confirm the recipient is deliverable (i.e., after
find_agent(config, agent_name) returns non-None, after the agent.get("status")
== "running" check, and after the bridge != None check), keep honoring the
forced = force_by_slug.get(agent_name, False) bypass (do not call try_acquire if
forced is True), and leave the next_hops > max_hops short-circuit where it is.

---

Nitpick comments:
In `@tinyagentos/scripts/install_openai-agents-sdk.sh`:
- Around line 31-81: The reviewer wants the duplicated bridge scaffold (context
rendering, NO_RESPONSE suppression, posting, and handler flow) extracted into a
shared module/template rather than copied into each installer; refactor the
common functions _render_context, _suppress, post_reply, and handle into a
single shared utility (or template) and import/use them from
tinyagentos/scripts/install_openai-agents-sdk.sh, leaving only bridge-specific
pieces (_build and _run) in this file; ensure the shared module exposes the same
function signatures (context list input, suppression semantics, post_reply
signature, and an async handler wrapper) so you can call the shared handler with
the bridge-specific _build/_run implementations and maintain current behavior
and logging.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 50cc94cc-728e-4d36-bfe0-0684f23fd89e

📥 Commits

Reviewing files that changed from the base of the PR and between 8ccab65 and fd70ab8.

📒 Files selected for processing (14)
  • docs/superpowers/specs/2026-04-19-chat-phase-1-conversational-core-design.md
  • tests/e2e/test_roundtable_group_chat.py
  • tests/test_bridge_session_phase1.py
  • tests/test_chat_context_window.py
  • tests/test_chat_group_policy.py
  • tests/test_chat_reactions.py
  • tinyagentos/agent_chat_router.py
  • tinyagentos/chat/context_window.py
  • tinyagentos/chat/group_policy.py
  • tinyagentos/chat/reactions.py
  • tinyagentos/scripts/install_langroid.sh
  • tinyagentos/scripts/install_openai-agents-sdk.sh
  • tinyagentos/scripts/install_openai_agents_sdk.sh
  • tinyagentos/scripts/install_pocketflow.sh
✅ Files skipped from review due to trivial changes (3)
  • tinyagentos/chat/context_window.py
  • tests/test_chat_context_window.py
  • tests/test_chat_group_policy.py
🚧 Files skipped from review as they are similar to previous changes (7)
  • tests/test_bridge_session_phase1.py
  • tinyagentos/scripts/install_openai_agents_sdk.sh
  • tests/test_chat_reactions.py
  • tinyagentos/chat/group_policy.py
  • tinyagentos/scripts/install_pocketflow.sh
  • tests/e2e/test_roundtable_group_chat.py
  • docs/superpowers/specs/2026-04-19-chat-phase-1-conversational-core-design.md

Comment thread tinyagentos/agent_chat_router.py Outdated
Comment thread tinyagentos/agent_chat_router.py Outdated
Comment thread tinyagentos/chat/reactions.py
…hoist, regenerate prompt)

- agent_chat_router: coerce metadata.hops_since_user to int before
  arithmetic; avoids TypeError on malformed client payloads.
- agent_chat_router: hoist context-window build out of the per-recipient
  loop — in a 5-agent lively channel we were doing 5 identical DB reads
  and context-window builds per message.
- reactions: 👎 regenerate now fetches the ORIGINAL user message via
  trace_id and sends THAT as the prompt, not the agent's disliked reply.
  Previously the bridge would receive its own output as the next user
  turn and produce nonsense.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tinyagentos/agent_chat_router.py (1)

109-146: ⚠️ Potential issue | 🟠 Major

Acquire group-policy quota only after the send is actually possible.

policy.try_acquire() runs before Line 115's agent lookup and the later status/bridge guards. Because try_acquire() is now atomic check+record, a missing or offline agent still burns cooldown/channel budget, which can block later valid deliveries in the same room. Move the acquire to just before enqueue_user_message().

Suggested fix
         for agent_name in recipients:
             forced = force_by_slug.get(agent_name, False)
             if not forced:
                 if next_hops > max_hops:
                     continue
-                if policy is not None and not policy.try_acquire(channel["id"], agent_name, settings):
-                    continue
             agent = find_agent(config, agent_name)
             if agent is None:
                 continue
             if agent.get("status") != "running":
                 await self._post_system_reply(
@@
             if bridge is None:
                 await self._post_system_reply(
                     agent_name, channel["id"],
                     "[router] bridge registry not configured on this host.",
                 )
                 continue
+            if not forced and policy is not None and not policy.try_acquire(channel["id"], agent_name, settings):
+                continue

             await bridge.enqueue_user_message(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/agent_chat_router.py` around lines 109 - 146, The
policy.try_acquire() call should be moved from before the agent lookup to just
before actually sending to avoid consuming quota for missing/offline agents:
keep computing forced = force_by_slug.get(...), perform find_agent(config,
agent_name), the agent status check (agent.get("status")), and bridge presence
check first; then just before calling bridge.enqueue_user_message(...) if policy
is not None and not forced call policy.try_acquire(channel["id"], agent_name,
settings) and if it fails continue; after a successful enqueue (i.e.,
immediately after await bridge.enqueue_user_message(...)) call
policy.record_send(channel["id"], agent_name) only when not forced and policy is
not None.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tinyagentos/agent_chat_router.py`:
- Around line 134-135: The router currently overwrites the trace by always
setting "trace_id": message.get("id"); instead preserve an incoming root trace
if present by setting "trace_id" to message.get("metadata", {}).get("trace_id")
or fallback to message.get("id"). Update the code that builds the forwarded
message (the dict where "id": message.get("id") is set) to prefer the existing
metadata.trace_id from the incoming message before using the current message id
so downstream agent hops retain the original user-turn correlation.
- Around line 63-79: The code narrows recipients to only explicitly mentioned
agents when mentions.explicit is true, which breaks the "lively" behavior;
update the mentions.explicit branch in agent_chat_router.py so that if
effective_mode == "lively" you keep recipients = list(candidates) (fan-out to
all non-muted members) but still set force_by_slug for each slug in
mentions.explicit, otherwise retain the current filtering behavior (recipients =
[m for m in candidates if m in mentions.explicit]). Adjust the logic around
force_by_slug, mentions.explicit, candidates, recipients, and effective_mode to
ensure only the mentioned agents get force_by_slug=True while lively mode still
delivers to all candidates.

In `@tinyagentos/chat/reactions.py`:
- Around line 47-55: The original-message handling currently swallows errors and
can leave original_text empty, which allows the regenerate path to enqueue an
empty prompt (e.g., with force_respond=True); update the logic around
msg_store.get_message (and the variables original_text and original_id) to treat
a failed lookup or empty content as a hard failure: if original lookup raises or
returns no usable content, do not proceed to enqueue/force a regenerate—return
or skip enqueueing instead; add an explicit truthy check on original_text before
calling the enqueue/regenerate code path so an empty string will not be
enqueued.
- Around line 24-29: WantsReplyRegistry.list currently writes back an empty dict
into self._entries for channels with no alive timestamps, which lets _entries
grow; change the logic in WantsReplyRegistry.list (use variables now, bucket,
alive, self._ttl) so that after computing alive you only assign
self._entries[channel_id] = alive when alive is non-empty, and otherwise remove
the key (e.g., self._entries.pop(channel_id, None)) to avoid retaining empty
buckets; keep the returned value as sorted(alive).

---

Outside diff comments:
In `@tinyagentos/agent_chat_router.py`:
- Around line 109-146: The policy.try_acquire() call should be moved from before
the agent lookup to just before actually sending to avoid consuming quota for
missing/offline agents: keep computing forced = force_by_slug.get(...), perform
find_agent(config, agent_name), the agent status check (agent.get("status")),
and bridge presence check first; then just before calling
bridge.enqueue_user_message(...) if policy is not None and not forced call
policy.try_acquire(channel["id"], agent_name, settings) and if it fails
continue; after a successful enqueue (i.e., immediately after await
bridge.enqueue_user_message(...)) call policy.record_send(channel["id"],
agent_name) only when not forced and policy is not None.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f1792586-d7e4-44cc-bd66-f20ae5f0c8dc

📥 Commits

Reviewing files that changed from the base of the PR and between fd70ab8 and 892cc27.

📒 Files selected for processing (3)
  • tests/test_chat_reactions.py
  • tinyagentos/agent_chat_router.py
  • tinyagentos/chat/reactions.py
✅ Files skipped from review due to trivial changes (1)
  • tests/test_chat_reactions.py

Comment on lines +63 to +79
force_by_slug: dict[str, bool] = {}
if mentions.all:
for m in candidates:
force_by_slug[m] = True
recipients = list(candidates)
elif mentions.explicit:
recipients = [m for m in candidates if m in mentions.explicit]
for m in recipients:
force_by_slug[m] = True
elif channel_type == "dm":
recipients = list(candidates)
for m in recipients:
force_by_slug[m] = True
elif effective_mode == "quiet":
recipients = []
else:
recipients = list(candidates)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep lively rooms fan-out even when someone is mentioned.

In a lively channel, Line 68 currently narrows recipients to only the explicitly mentioned agents. That contradicts the contract in the docstring: lively should still route to all non-muted members, with only the mentioned agents marked force_respond=True. As written, @agent-a silences every other agent in lively mode.

Suggested fix
         force_by_slug: dict[str, bool] = {}
-        if mentions.all:
-            for m in candidates:
-                force_by_slug[m] = True
-            recipients = list(candidates)
-        elif mentions.explicit:
-            recipients = [m for m in candidates if m in mentions.explicit]
-            for m in recipients:
-                force_by_slug[m] = True
-        elif channel_type == "dm":
+        if channel_type == "dm":
             recipients = list(candidates)
-            for m in recipients:
-                force_by_slug[m] = True
+            force_by_slug = {m: True for m in recipients}
         elif effective_mode == "quiet":
-            recipients = []
+            if mentions.all:
+                recipients = list(candidates)
+                force_by_slug = {m: True for m in recipients}
+            else:
+                recipients = [m for m in candidates if m in mentions.explicit]
+                force_by_slug = {m: True for m in recipients}
         else:
             recipients = list(candidates)
+            if mentions.all:
+                force_by_slug = {m: True for m in recipients}
+            else:
+                force_by_slug = {m: True for m in recipients if m in mentions.explicit}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/agent_chat_router.py` around lines 63 - 79, The code narrows
recipients to only explicitly mentioned agents when mentions.explicit is true,
which breaks the "lively" behavior; update the mentions.explicit branch in
agent_chat_router.py so that if effective_mode == "lively" you keep recipients =
list(candidates) (fan-out to all non-muted members) but still set force_by_slug
for each slug in mentions.explicit, otherwise retain the current filtering
behavior (recipients = [m for m in candidates if m in mentions.explicit]).
Adjust the logic around force_by_slug, mentions.explicit, candidates,
recipients, and effective_mode to ensure only the mentioned agents get
force_by_slug=True while lively mode still delivers to all candidates.

Comment on lines 134 to +135
"id": message.get("id"),
"trace_id": message.get("id"), # MVP: message id IS the trace id
"trace_id": message.get("id"),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve the root trace_id across agent-to-agent hops.

Line 135 resets every forwarded message's trace_id to the current message id. When this router reroutes an agent reply that already carries metadata.trace_id, downstream hops lose the original user-turn correlation, which also breaks trace-based reaction/regeneration lookups. Prefer the incoming trace when present.

Suggested fix
                 {
                     "id": message.get("id"),
-                    "trace_id": message.get("id"),
+                    "trace_id": (
+                        message.get("trace_id")
+                        or (message.get("metadata") or {}).get("trace_id")
+                        or message.get("id")
+                    ),
                     "channel_id": message.get("channel_id"),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"id": message.get("id"),
"trace_id": message.get("id"), # MVP: message id IS the trace id
"trace_id": message.get("id"),
{
"id": message.get("id"),
"trace_id": (
message.get("trace_id")
or (message.get("metadata") or {}).get("trace_id")
or message.get("id")
),
"channel_id": message.get("channel_id"),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/agent_chat_router.py` around lines 134 - 135, The router
currently overwrites the trace by always setting "trace_id": message.get("id");
instead preserve an incoming root trace if present by setting "trace_id" to
message.get("metadata", {}).get("trace_id") or fallback to message.get("id").
Update the code that builds the forwarded message (the dict where "id":
message.get("id") is set) to prefer the existing metadata.trace_id from the
incoming message before using the current message id so downstream agent hops
retain the original user-turn correlation.

Comment on lines +24 to +29
def list(self, channel_id: str) -> list[str]:
now = _now()
bucket = self._entries.get(channel_id) or {}
alive = {s: t for s, t in bucket.items() if (now - t) < self._ttl}
self._entries[channel_id] = alive
return sorted(alive)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid retaining empty channel buckets in WantsReplyRegistry.list().

Line 28 writes back empty dicts for misses/expired channels, which can grow _entries indefinitely under repeated reads of random channel IDs.

Proposed fix
     def list(self, channel_id: str) -> list[str]:
         now = _now()
-        bucket = self._entries.get(channel_id) or {}
+        bucket = self._entries.get(channel_id)
+        if not bucket:
+            return []
         alive = {s: t for s, t in bucket.items() if (now - t) < self._ttl}
-        self._entries[channel_id] = alive
+        if alive:
+            self._entries[channel_id] = alive
+        else:
+            self._entries.pop(channel_id, None)
         return sorted(alive)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/chat/reactions.py` around lines 24 - 29, WantsReplyRegistry.list
currently writes back an empty dict into self._entries for channels with no
alive timestamps, which lets _entries grow; change the logic in
WantsReplyRegistry.list (use variables now, bucket, alive, self._ttl) so that
after computing alive you only assign self._entries[channel_id] = alive when
alive is non-empty, and otherwise remove the key (e.g.,
self._entries.pop(channel_id, None)) to avoid retaining empty buckets; keep the
returned value as sorted(alive).

Comment on lines +47 to +55
if msg_store is not None and trace_id:
try:
original = await msg_store.get_message(trace_id)
if original:
original_text = original.get("content") or ""
original_id = original.get("id") or original_id
except Exception:
original_text = ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Regenerate should not enqueue when original prompt resolution fails.

If original lookup fails or returns no content, Line 72 can enqueue an empty text with force_respond=True. That can trigger nonsensical retries instead of a true regenerate.

Proposed fix
-        original_text = ""
-        original_id = message.get("id")
-        trace_id = (message.get("metadata") or {}).get("trace_id") or message.get("id")
+        original_text = ""
+        original_id = message.get("id")
+        trace_id = (message.get("metadata") or {}).get("trace_id")
+        if not trace_id:
+            return
         if msg_store is not None and trace_id:
             try:
                 original = await msg_store.get_message(trace_id)
                 if original:
                     original_text = original.get("content") or ""
                     original_id = original.get("id") or original_id
             except Exception:
-                original_text = ""
+                return
@@
+        if not original_text.strip():
+            return
         await bridge.enqueue_user_message(
             message["author_id"],
             {

Also applies to: 65-77

🧰 Tools
🪛 Ruff (0.15.10)

[warning] 53-53: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/chat/reactions.py` around lines 47 - 55, The original-message
handling currently swallows errors and can leave original_text empty, which
allows the regenerate path to enqueue an empty prompt (e.g., with
force_respond=True); update the logic around msg_store.get_message (and the
variables original_text and original_id) to treat a failed lookup or empty
content as a hard failure: if original lookup raises or returns no usable
content, do not proceed to enqueue/force a regenerate—return or skip enqueueing
instead; add an explicit truthy check on original_text before calling the
enqueue/regenerate code path so an empty string will not be enqueued.

@jaylfc jaylfc merged commit b9bfc6a into master Apr 19, 2026
8 checks passed
@jaylfc jaylfc deleted the feat/chat-phase-1-conversational-core branch April 19, 2026 14:20
jaylfc added a commit that referenced this pull request Apr 19, 2026
…ckages

Master had the install scripts from PR #234 but the deployer was still
calling bare 'pip3 install <framework>'. That path both (a) skips the
bridge setup the scripts do — NO_RESPONSE gating, context rendering,
force_respond handling — and (b) fails on Debian 13+ containers with
PEP 668's externally-managed-environment error.

When tinyagentos/scripts/install_<framework>.sh exists for the requested
framework, push it into the container and run it; otherwise fall back to
pip with the --break-system-packages flag so generic frameworks still
install on modern Debian. Script path gets a 15-minute timeout because
uv/hermes/langroid setup downloads can run long.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant