Skip to content

feat(agent): add bootstrap_room_message for self-starting agents#295

Draft
darvell-thenvoi wants to merge 7 commits into
mainfrom
feat/kickoff-bootstrap-room-message
Draft

feat(agent): add bootstrap_room_message for self-starting agents#295
darvell-thenvoi wants to merge 7 commits into
mainfrom
feat/kickoff-bootstrap-room-message

Conversation

@darvell-thenvoi

@darvell-thenvoi darvell-thenvoi commented May 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a single new public Agent API for self-starting agents — work that begins without a real platform message arriving:

async with agent:
    room_id = await agent.bootstrap_room_message(
        "Find out the current weather in SF and Paris, then tell me which is more pleasant."
    )
    await agent.run_forever()

Agent.bootstrap_room_message accepts either shape:

  • Plain string (the common case): the method creates a fresh chat room (optionally task-linked via task_id=), wraps the string in a synthetic PlatformMessage, and injects it. Returns the new room id.
  • A fully-constructed PlatformMessage + room_id: the escape hatch for callers that need stable message ids for retry/replay idempotency (webhooks, cron jobs, external bridges that need to dedupe). Returns the same room id back.

The injected message is delivered to the adapter exactly like a real chat message but is not persisted: no DB write, no mark_processing / mark_processed lifecycle, no retry tracking, other participants never see it.

How it works

  1. Agent.bootstrap_room_message (string shape) calls REST create_agent_chat to mint a room, builds a synthetic PlatformMessage, and forwards to the runtime.
  2. PlatformRuntime.bootstrap_room_message requires the runtime to be started and forwards to AgentRuntime.
  3. AgentRuntime.bootstrap_room_message ensures the room is subscribed (claim-the-slot-then-subscribe with rollback on failure) and an ExecutionContext exists, then delegates to ExecutionContext.bootstrap_message.
  4. ExecutionContext.bootstrap_message wraps the PlatformMessage in a synthetic MessageEvent (sender_type="System", sender_id="kickoff") and enqueues it via on_event. The existing synthetic-message branch in _process_event skips DB persistence and retry tracking.

The synthetic identity is forced inside ExecutionContext.bootstrap_message — callers cannot override it, because that combination is what triggers the persistence-skip.

Two safety guards:

  • on_event skips the _first_ws_msg_id sync-point marker for synthetic messages so a bootstrap arriving before any real WS message can't poison the /next resync.
  • _handle_room_added early-returns when the room is already tracked, avoiding a duplicate Phoenix join when bootstrap has already claimed and subscribed before the platform's room_added event arrives.

Synthetic-sender check, broadened safely

The _process_event synthetic shortcut used to match a single sender_id == SYNTHETIC_CONTACT_EVENTS_SENDER_ID. It now matches sender_id in SYNTHETIC_SENDER_IDS (a closed frozenset of known synthetic ids). The check still requires sender_type == "System" AND a recognized id, so real platform "System" messages keep their full mark_* lifecycle. A regression test pins this.

Files

File Change
src/thenvoi/agent.py bootstrap_room_message(str | PlatformMessage, *, room_id=None, ...) — single public method
src/thenvoi/runtime/types.py SYNTHETIC_KICKOFF_SENDER_ID, SYNTHETIC_KICKOFF_SENDER_NAME, SYNTHETIC_SENDER_IDS
src/thenvoi/runtime/execution.py bootstrap_message impl + Protocol declaration; on_event sync-marker guard; broadened synthetic check
src/thenvoi/runtime/runtime.py AgentRuntime.bootstrap_room_message (subscribe + ensure execution + bootstrap, with rollback)
src/thenvoi/runtime/platform_runtime.py thin forwarder
src/thenvoi/runtime/presence.py early-return in _handle_room_added for already-tracked rooms
tests/runtime/test_kickoff.py 13 tests: synthetic event delivery, no platform persistence, caller-id preservation, sync-marker safety, real-System-message regression, runtime subscribe/dedupe, public-entry string + PlatformMessage shapes, missing-room_id guard, not-started guard, room_added dedupe regression
examples/scenarios/self_start_kickoff.py runnable demo using the existing anthropic_agent config stanza

Live smoke test

Ran python examples/scenarios/self_start_kickoff.py against app.thenvoi.com (real REST + WS). The example calls agent.bootstrap_room_message("Find out the current weather in San Francisco and Paris...") with no room_id and no custom prompt.

Observed log sequence:

thenvoi.adapters.anthropic: Anthropic adapter started for agent: Claude Code
thenvoi.runtime.presence:   RoomPresence started for agent 81c89ef9-…
thenvoi.runtime.platform_runtime: Platform runtime started for agent: Claude Code
thenvoi.agent:              Agent started: Claude Code (thenvoi-sdk 0.2.8)
thenvoi.agent:              Bootstrap created new room: 0cff218f-9b74-4951-b55e-f5b522dcc73b
thenvoi.runtime.execution:  Bootstrap (kickoff) message kickoff:82071a56-… enqueued for room 0cff218f-…
thenvoi.preprocessing.default: Room 0cff218f-…: Got 0 messages
thenvoi.adapters.anthropic: Room 0cff218f-…: Calling Anthropic with 2 messages (first_msg=True)

What this proves end-to-end:

  • The string shape made the REST create_agent_chat call and got a fresh room back. The room id propagated all the way to the adapter.
  • bootstrap_message enqueued a synthetic MessageEvent with the auto-generated id kickoff:82071a56-….
  • The synthetic message was NOT loaded from the platform (Got 0 messages) — it was injected in-memory only, exactly as designed.
  • The Anthropic adapter received it with first_msg=True and dispatched the model call.
  • No mark_processing / mark_processed calls fired against the platform for the synthetic id (verified in unit tests; the live run hit the same code path).

The agent then made an Anthropic API call against the bootstrap message. We did not wait for the model response in this run; the goal was to prove the new code path delivers the message to the adapter, which it does.

Test plan

  • Unit: uv run pytest tests/runtime/test_kickoff.py -v — 13/13 passing
  • Unit suite: uv run pytest tests/runtime/ tests/adapters/ tests/converters/ tests/preprocessing/ --no-cov -q — 1461 passing
  • Lint: uv run ruff check . && uv run ruff format . — clean
  • Types: uv run pyrefly check — 0 errors
  • Live smoke (string shape): python examples/scenarios/self_start_kickoff.py against app.thenvoi.com — confirmed bootstrap → room creation → synthetic enqueue → adapter delivery → model call
  • Reviewer-side live run before un-drafting

@darvell-thenvoi darvell-thenvoi changed the title feat(runtime): add Agent.kickoff for self-starting agents feat(runtime): add Agent.bootstrap_room_message for self-starting agents May 7, 2026
@darvell-thenvoi darvell-thenvoi changed the title feat(runtime): add Agent.bootstrap_room_message for self-starting agents feat(agent): add bootstrap_room_message for self-starting agents May 7, 2026
@darvell-thenvoi

Copy link
Copy Markdown
Collaborator Author

Merged current origin/dev into the draft branch in a8aeb12 to clear the dirty merge state.

Conflict resolution:

  • src/thenvoi/runtime/execution.py kept both sides: the reconnect-resync handling from dev and the bootstrap branch's synthetic-message guard for _first_ws_msg_id. Synthetic kickoff/contact messages still do not poison /next synchronization, while real reconnect events still schedule catch-up.

Validation after merge:

  • uv run pytest tests/runtime/test_kickoff.py tests/test_session_sync.py tests/runtime/test_presence.py -q --no-cov -> 59 passed
  • uv run ruff check src/thenvoi/agent.py src/thenvoi/runtime/execution.py src/thenvoi/runtime/platform_runtime.py src/thenvoi/runtime/presence.py src/thenvoi/runtime/runtime.py src/thenvoi/runtime/types.py tests/runtime/test_kickoff.py tests/test_session_sync.py tests/runtime/test_presence.py examples/scenarios/self_start_kickoff.py -> passed
  • uv run ruff format --check src/thenvoi/agent.py src/thenvoi/runtime/execution.py src/thenvoi/runtime/platform_runtime.py src/thenvoi/runtime/presence.py src/thenvoi/runtime/runtime.py src/thenvoi/runtime/types.py tests/runtime/test_kickoff.py tests/test_session_sync.py tests/runtime/test_presence.py examples/scenarios/self_start_kickoff.py -> passed
  • Merge commit hooks passed: hardcoded secrets, ruff, format, pyrefly, commitizen.

Still draft; I did not mark ready for review.

Base automatically changed from dev to main May 26, 2026 15:46
@darvell-thenvoi darvell-thenvoi force-pushed the feat/kickoff-bootstrap-room-message branch from 0d0ae15 to 8550307 Compare May 27, 2026 01:49
@darvell-thenvoi darvell-thenvoi changed the base branch from main to dev May 27, 2026 02:15
darvell-thenvoi and others added 7 commits May 26, 2026 19:22
Lets an agent start work on its own with an initial message that did
not come through the platform. `Agent.kickoff(content)` creates a fresh
chat room (or uses a supplied room_id) and injects a synthetic message;
`Agent.bootstrap_room_message(room_id, message)` is the low-level
primitive for callers that need control over message.id (retry/replay
idempotency).

The injected message is delivered to the adapter exactly like a real
chat message but lives only in memory: no DB write, no
mark_processing/mark_processed lifecycle, no retry tracking, other
participants never see it. Reuses the existing synthetic-sender
mechanism with a new SYNTHETIC_KICKOFF_SENDER_ID and a closed
SYNTHETIC_SENDER_IDS set so real platform "System" messages keep their
full mark_* lifecycle. on_event also skips the _first_ws_msg_id sync
marker for synthetic messages so a kickoff arriving before any real WS
message can't poison the /next sync point.
- Fix stale log message in ExecutionContext synthetic-message branch
  (still said "contact event message" after the broadening change).
  Now logs the actual sender_id.
- Claim presence.rooms slot before awaiting subscribe_room so concurrent
  bootstrap calls for the same fresh room don't double-subscribe; roll
  back the claim if subscribe fails.
- Refactor TestAgentKickoff to drive through the public Agent
  constructor with a stubbed PlatformRuntime instead of __new__-ing and
  poking private attributes.
- Add a public-entry test that proves caller-supplied PlatformMessage
  ids reach the adapter unchanged through the full Agent →
  PlatformRuntime → AgentRuntime → ExecutionContext chain.
- Add a guard test that kickoff() raises before start().
- Note in the kickoff docstring that it is safe to call repeatedly.
- Drop dead sender_id/sender_name kwargs from Agent.kickoff. The
  synthetic identity is load-bearing: ExecutionContext.bootstrap_message
  forces sender_type=System and sender_id=kickoff so the
  persistence-skip in _process_event triggers. Exposing the kwargs let
  callers think they could override identity when the implementation
  ignored them. Comment explains why the sender fields in the
  PlatformMessage are fixed.
- Declare bootstrap_message on the Execution Protocol with a
  versionchanged note, mirroring the request_resync precedent. Custom
  Execution implementations now get typed conformance instead of a
  silent RuntimeError at the bootstrap call site.
- Skip duplicate work in RoomPresence._handle_room_added when the room
  is already tracked. Avoids a duplicate Phoenix join when kickoff has
  already claimed and subscribed to a fresh room before the platform's
  room_added WS event arrives.
- Improve Agent.kickoff error message to include task_id when
  create_agent_chat returns no data.
- Add fire-and-forget delivery note to Agent.kickoff and
  Agent.bootstrap_room_message docstrings.
- Tests: assert the synthetic message reaches the adapter on the
  create-room path; cover the not-started guard on
  bootstrap_room_message; cover the create-chat-returns-no-data error;
  add a regression test that room_added for a kickoff-claimed room
  does not call subscribe_room twice.
Demonstrates Agent.kickoff() — the recommended public entry point for
self-starting agents driven by webhooks, cron, or any source where the
initial work item does not arrive as a platform message. Uses the
existing anthropic_agent config stanza so no new credentials are
needed.
The previous example asked the agent to answer alone, which doesn't
demonstrate why kickoff matters on a collaboration platform. Rewrite
the kickoff message to drop the agent into an empty room and tell it
to discover peers, invite a collaborator, and delegate the weather
lookup. Bump model to claude-sonnet-4-6.
Drop Agent.kickoff. Agent.bootstrap_room_message now accepts either:

  - a plain string: the most common case. The method creates a fresh
    chat room (optionally task-linked), wraps the string in a synthetic
    PlatformMessage, and injects it.
  - a fully-constructed PlatformMessage with an explicit room_id: the
    escape hatch for callers that need stable message ids for
    retry/replay idempotency (webhooks, cron, external bridges).

One public method, two ergonomic shapes. Returns the room id in both
cases so callers can refer to it later. Raises ValueError if a
PlatformMessage is passed without room_id.

The synthetic identity (sender_type=System, sender_id=kickoff) is still
forced inside ExecutionContext.bootstrap_message — caller cannot
override, because that's what triggers the persistence-skip in
_process_event.

Tests: replace TestAgentKickoff with TestAgentBootstrap covering both
shapes plus the not-started and missing-room_id guards.

Verified live against app.thenvoi.com — the string call creates the
room, enqueues the synthetic message, and the Anthropic adapter
receives it with first_msg=True.
@darvell-thenvoi darvell-thenvoi force-pushed the feat/kickoff-bootstrap-room-message branch from 8550307 to 4044a6e Compare May 27, 2026 02:24
Base automatically changed from dev to main June 22, 2026 10:58
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.

2 participants