Skip to content

Route events to an existing conversation via an external subject mapping #362

Description

@VascoSch92

Phase 5 of 5 · part of #363

Blocked by: #358 and #359 — both closed, so this is unblocked.
Blocks: nothing. #358#361 do not depend on this.

One nullable column on automation_runs. No new table.

Background

Every matched event creates a brand-new AutomationRun, and therefore a brand-new
conversation with no memory of what came before.

On the OSS VM this is visible today: each app_mention in a Slack thread starts a
fresh run. Someone can @-mention the bot, get an answer, reply in-thread with a
follow-up, and the bot answers the follow-up as though it had never seen the
thread. The same shape applies to repeated events on one GitHub PR or one Linear
issue.

PR #118 attempted this with a SessionConfig on EventTrigger, JMESPath
session-key extraction, an AutomationSession model, a PendingSessionEvent
queue, and active/dead/expired session states. Its diagnosis of the user problem
is right. Its mechanism makes the automation sandbox a long-lived event consumer,
and the PR itself notes it still needs agent-server event queue endpoints, an SDK
polling loop inside the sandbox, and sandbox restart with event requeue.

That is a substantial amount of lifecycle machinery to add inside a system whose
central abstraction is a finite run.

Problem

Threaded, multi-turn interactions over an external subject (a Slack thread, a PR,
an issue) cannot share context, and the obvious fix builds a second conversation
system inside the sandbox.

Proposed change

Keep conversations where they already live, and derive the conversation id
instead of storing a mapping.

Principle: the id is a documented, deterministic function of the external
subject.
Nothing is looked up to compute it, so there is no table, no
migration, and nothing that can dangle or need expiring.

conversation_id = uuid5(
    AUTOMATION_CONVERSATION_NS,
    f"{org_id}/{automation_id}/{source}/{subject_key}",
)

AUTOMATION_CONVERSATION_NS is pinned permanently — changing it orphans every
live thread. automation_id is in the key on purpose: editing an automation
re-keys its threads. Without it a thread stays pinned to whatever agent and
config the first event saw, and attaching with a different agent kind raises
(_agent_kind_mismatch_message).

Providers emit an optional subject on AcceptedEvent (the field was reserved in
phase 1, and the extractor hook in phase 2's descriptor):

  • Slack — team/channel/thread_ts
  • GitHub — owner/repo#number

The agent server supports this directly — verified against the source:

  • ConversationConfig.conversation_id is caller-supplied (request.py:117), and
    _start_conversation does request.conversation_id or uuid4().
  • Starting with an existing id does not error — it attaches and returns 200
    instead of 201 (conversation_router.py:230).
  • The SDK already implements attach-or-create: RemoteConversation.__init__ GETs
    /conversations/{id} and creates only on 404, so Conversation(conversation_id=...)
    needs no branching from us.
  • send_message has no execution-status guard, so a new turn continues an IDLE,
    ERROR or STUCK conversation. The only refusal is conversation_already_running,
    which re-arms rather than dropping the message.

Reaching the sandbox

The id is derivable; the sandbox is not. In cloud each run gets a fresh
sandbox that is hard-DELETEd when it finishes (backends/cloud.py
delete_sandbox), and conversations live on the sandbox's own filesystem
(conversations_path), so a derived id alone would land in a sandbox that has
never seen it.

So a run that owns a subject keeps its sandbox — complete_run treats it like
keep_alive, and the runtime TTL reaper still collects it eventually. A later
event finds that sandbox through AutomationRun.subject_key, one nullable
indexed column, and delivers a turn to it directly. That is what makes a
continued event cost no run at all, which a rule on its own cannot provide.

The column is not a mapping: it is never read to decide the conversation id,
only to find the sandbox. When the sandbox is gone the turn fails, the column
is cleared, and the event falls back to a run.

Flow:

event → derive subject_key → lock the subject's most recent run on a sandbox
      → found?      derive the conversation id, send a turn, create no run
      → otherwise   create a run, carrying the subject on it

Reuse from PR #118

  • the key_expr concept and its session-key extraction tests
  • the requirement that events for one subject be serialised rather than processed
    concurrently
  • the worked examples (Slack thread_ts, GitHub issue/PR number)
  • the principle that this behaviour is opt-in per route/trigger

Do not adopt from PR #118

  • PendingSessionEvent and any SDK-side polling loop inside the sandbox
  • session lifecycle states (active/dead/expired) owned by the automation service
  • sandbox restart plus event requeue semantics

Open questions

  1. Should this be opt-in per trigger or per automation?

Acceptance criteria

  • A second app_mention in the same Slack thread reaches a run that has the
    context of the first. Stated behaviourally on purpose: with the transcript
    fallback it will not always be the same conversation.
  • Two events in different threads share no context.
  • Automations that do not opt in behave exactly as today.
  • A subject whose sandbox is gone degrades to creating a new run, not an error.
  • Concurrent events for one subject do not race into two conversations.

References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions