Skip to content

feat: external agent chat#259

Open
cyruszhang wants to merge 1 commit into
mainfrom
claude/issue-257-20260714-1803
Open

feat: external agent chat#259
cyruszhang wants to merge 1 commit into
mainfrom
claude/issue-257-20260714-1803

Conversation

@cyruszhang

Copy link
Copy Markdown
Collaborator

Implements the external agent chat feature requested in #257: a trial-scoped ChatMessageEvent, authenticated gateway routes (POST/GET /messages), CLI/SDK support (dojozero-agent chat/messages), and arena UI rendering. Guardrails: 500-char limit, blank rejection, per-agent rate limiting, idempotency key.

Closes #257.

⚠️ Tests/lint/typecheck could not be executed in the authoring session (tool permissions) — please run uv run pytest, uv run ruff check packages/, and uv run pyright before merging.

Generated with Claude Code

Adds a trial-scoped chat channel for external agents, following the
existing predictions/bets pattern end-to-end:

- ChatMessageEvent DataEvent, registered in the AnyDataEvent union so
  chat persists to JSONL, replays in backtests, and streams via spans.
- ExternalAgentAdapter.send_message/get_messages: length/blank
  validation, idempotency key support, persistence via
  DataHub.receive_event.
- Gateway routes POST /messages, GET /messages, gated by the same
  get_agent_id auth + registration check as every other route, plus a
  new RateLimitType.CHAT wired into GatewayState.
- dojozero_client: TrialConnection.send_message/get_messages,
  MessageRejectedError, daemon RPC handlers, and
  `dojozero-agent chat/messages` CLI subcommands.
- Arena UI: chat messages already render via the existing
  agent-response feed branch (content field); added a small badge to
  distinguish chat from agent reasoning.

Does not touch/reuse SocialBoard, which stays internal-agent-only per
the issue's guidance.

Closes #257.

Co-authored-by: Cyrus Zhang <2977989+cyruszhang@users.noreply.github.com>
@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review

Solid, well-tested implementation that follows the existing patterns closely (ChatMessageEvent is correctly wired into the AnyDataEvent discriminated union, EventTypes enum, and round-trips through span serialization; gateway routes mirror the /bets auth/registration flow; client/daemon/CLI layers match the bet command shape). One correctness issue and a couple of minor points below.

Correctness

Idempotency key has a TOCTOU race (packages/dojozero/src/dojozero/gateway/_adapter.py:704-725, send_message). The idempotency cache is checked, then await self._data_hub.receive_event(...) runs, and only after that await does the code write self._chat_idempotency[key] = response. Two concurrent POSTs with the same idempotencyKey can both pass the initial cached is None check before either write lands, so both get persisted as separate ChatMessageEvents — defeating the guardrail the PR description specifically calls out. Note the pre-existing _idempotency_keys bet-dedup path (_adapter.py:571-674) has the identical shape, so this isn't new to this PR, but since chat idempotency is being introduced as a stated guardrail here, it's worth either fixing (e.g. reserve the key synchronously before the await) or at least flagging as a known limitation.

Minor / non-blocking

  • _chat_idempotency: dict[str, ChatMessageResponse] (_adapter.py:134) is unbounded — unlike _chat_messages (capped via deque(maxlen=500)), idempotency keys are never evicted, so a long-lived trial with many unique keys grows this dict forever. Same issue exists for the bet idempotency dict, so it's consistent with current conventions, but worth a follow-up.
  • In post_message (_server.py:521-552), state.adapter.is_registered(agent_id) is checked in the route and then checked again inside adapter.send_message, whose ValueError is mapped to MESSAGE_REJECTED/400. Since the route already guards registration, that branch is effectively dead code today; if it ever does fire (e.g. a race with unregister) the error code would misleadingly say "message rejected" rather than "not registered." Harmless, just slightly confusing.
  • GET /messages and rate limiting: only POST /messages calls state.rate_limiter.check_rate_limit(...); GET /messages has no rate limit, same as other GET endpoints in this file (/bets, /predictions, etc.), so this is consistent with existing behavior rather than a gap introduced here.
  • MAX_CHAT_MESSAGE_LENGTH = 500 is defined in gateway/_models.py, but _cli.py's --content help text hardcodes "max 500 characters" separately — cosmetic duplication, not worth blocking on.

Coverage

Good test coverage on the gateway (_models, _adapter, HTTP routes, rate limiter) and client (ChatMessage.from_dict, TrialConnection.send_message/get_messages) layers, plus the arena span round-trip test. No tests were added for the new _daemon.py RPC handlers (_handle_chat/_handle_messages) or the CLI cmd_chat/cmd_messages functions, but that mirrors the existing gap for cmd_bet, so it's not a regression in test-coverage practice.

Type-checking / lint

Nothing jumped out — imports are used, Query/field_validator are properly imported, and the except ValueError as e: raise HTTPException(...) without raise ... from e matches the existing style throughout this file (not a new lint deviation).

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.

enable external agents to chat/interaect

2 participants