Skip to content

Origin anchor on unsolicited replies: tell the user where/why/when an agent message originated #426

@rockfordlhotka

Description

@rockfordlhotka

Problem

RockBot is a single persona that I interact with across many channels (Blazor, CLI, Discord, WhatsApp, Slack, Teams). The agent regularly produces unsolicited messages — subagent completions, scheduled tasks, A2A results, idle-batched inbox notifications — and these get fanned out on the broadcast topic user.response.<AgentName>, which every connected proxy subscribes to (intentional, see UserProxyService.cs:56-73).

When one of those bubbles lands in (say) Blazor, the user often has no idea:

  • Why — what was the original ask?
  • When — when did I start it?
  • Where — which channel was I in when I started it?

This is a real usability problem because work that spans long durations (deep research, A2A round-trips, scheduled patrols) regularly finishes after the originating session is gone, or in a different client than the one currently being watched. The user has to mentally reconstruct context that the agent already had at request time.

Today's behavior

AgentReply (src/RockBot.UserProxy.Abstractions/AgentReply.cs) carries only Content, SessionId, AgentName, IsFinal, IsCompletion, StructuredData, ContentType. The originating envelope's Source (proxy ID), the original prompt text, and the start timestamp are all dropped before the frontend ever sees them.

BlazorUserFrontend.CategorizeReply (src/RockBot.UserProxy.Blazor/Services/BlazorUserFrontend.cs:55-83) does infer some provenance from SessionId (scheduled-system, scheduled, a2a-inbound) and from AgentName (subagent-* prefix), but a regular session-tagged broadcast just falls into PrimaryFinal and renders as a context-free chat bubble.

Proposed design

Add an Origin sub-record to AgentReply that every unsolicited-publish site stamps, and let each frontend render an anchor preamble like:

↳ Re: \"deep research 8 historical topics\"
   started 21:14 from CLI · 2h 14m ago

The anchor is hidden when origin channel + session match the current viewing context (so it doesn't clutter the active conversation).

Data shape

public sealed record ReplyOrigin(
    string Channel,          // \"cli\" | \"blazor\" | \"discord\" | \"scheduled\" | \"a2a-inbound\" | …
    string PromptSummary,    // truncated or LLM-summarized first user turn
    DateTimeOffset StartedAt,
    string? SessionId        // original session, for deep-linking / dedupe
);

// AgentReply gains:
public ReplyOrigin? Origin { get; init; }

Single capture point

Add a session-keyed side-channel parallel to the existing SessionClientCapabilityStore:

// src/RockBot.Host/SessionOriginStore.cs (new)
public sealed class SessionOriginStore
{
    private readonly ConcurrentDictionary<string, ReplyOrigin> _byBaseSessionId = new();
    public void Set(string sessionId, ReplyOrigin origin) => _byBaseSessionId[sessionId] = origin;
    public ReplyOrigin? Get(string sessionId) => _byBaseSessionId.TryGetValue(sessionId, out var o) ? o : null;
}

Populate it once in UserMessageHandler.HandleAsync (~line 88, alongside clientCapabilityStore.Set):

originStore.Set(message.SessionId, new ReplyOrigin(
    Channel: ChannelOf(context.Envelope.Source),
    PromptSummary: Summarize(message.Content),
    StartedAt: DateTimeOffset.UtcNow,
    SessionId: message.SessionId));

Broadcast publish sites to update

The exhaustive inventory of user.response.<AgentName> publishers that need to read originStore and stamp reply.Origin:

Subagent path

  • src/RockBot.Subagent/SubagentResultHandler.cs:81 — Phase-1 completion bubble per subagent result
  • src/RockBot.Subagent/SubagentResultHandler.cs:246 — Phase-2 consolidated synthesis
  • src/RockBot.Subagent/SubagentResultHandler.cs:290 — Degraded synthesis fallback
  • src/RockBot.Subagent/SubagentProgressHandler.cs:53 — Subagent progress tick

A2A caller path

  • src/RockBot.A2A/A2ATaskStatusHandler.cs:71 — A2A working-state progress
  • src/RockBot.A2A/A2ATaskStatusHandler.cs:138 — A2A non-Working status synthesis
  • src/RockBot.A2A/A2ATaskResultHandler.cs:358 — A2A terminal-state synthesis
  • src/RockBot.A2A/A2ATaskResultHandler.cs:398 — A2A InputRequired max-rounds error
  • src/RockBot.A2A/A2ATaskErrorHandler.cs:142 — A2A terminal error synthesis

Synthetic origins (no lookup; synthesize the ReplyOrigin in place)

  • src/RockBot.Agent/ScheduledTaskHandler.cs:186Channel = \"scheduled\", PromptSummary = message.Description, StartedAt = now
  • src/RockBot.Host/InboundNotificationService.cs:105Channel = \"a2a-inbound\", PromptSummary = caller/skill summary, StartedAt = earliest notification timestamp

Out of scope (correlated / system)

  • UserFeedbackHandler.cs:186, CancelSessionHandler.cs:38, ClearContextHandler.cs:45, AgentProfileLoader.cs:177 — these are correlated acks or system notifications and don't need origin anchors.

Frontend rendering

  • src/RockBot.UserProxy.Blazor/Services/BlazorUserFrontend.cs and CLI/Discord/WhatsApp/etc. each gain a RenderOriginAnchor(reply) helper.
  • Suppress the anchor when reply.Origin.Channel == currentChannel && reply.Origin.SessionId == currentSessionId so it doesn't clutter active conversations.
  • Hardest sub-problem: relative-time formatting ("2 hours ago" / "yesterday" / "3 days ago"). Share a helper across frontends.

Two design choices to settle before implementing

  1. Prompt summarization strategy.
    • Option A: message.Content[..Math.Min(80, len)] — free, sometimes ugly.
    • Option B: LLM-summarize on capture — clean but adds entry-point latency.
    • Option C: Store both — truncated immediately, replace lazily with LLM summary when first downstream handler reads.
  2. Channel naming. envelope.Source currently carries the full ProxyId (e.g. cli-rocky-abc123). Either:
    • Add UserProxyOptions.ChannelName set per-frontend (cli/blazor/…) and propagate on UserMessage, or
    • Parse ProxyId by convention (cli-*cli, etc.).
      The explicit field is cleaner; the convention is faster to ship.

Why this is better than a per-bubble channel badge

A bare "from CLI" label tells you where but not what or when. Across 6 channels with overlapping work, the what is the part that re-grounds the user. Anchoring on the originating task (with its origin metadata) is also more durable than anchoring on the channel — it survives retries, re-issued requests, and follow-ups within the same logical thread.

Acceptance

  • An unsolicited subagent completion that arrives in Blazor after being started in CLI shows a single-line preamble identifying the original prompt, channel, and start time.
  • A scheduled task firing into any channel shows "scheduled · · ".
  • An a2a-inbound idle batch shows the calling agent + skill.
  • When viewing a bubble that originated in the current channel + session, the preamble is suppressed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions