From e4ed05b8152e6e734bb8bf30a95e89d042323471 Mon Sep 17 00:00:00 2001 From: jules Date: Sat, 2 May 2026 20:03:08 +1000 Subject: [PATCH] ai: agent hand-off awareness across personas in one conversation Two related fixes for the "switching agent mid-thread loses context" issue: 1. LLM-context fix The system prompt now includes a PRIOR HAND-OFF block whenever the current conversation has been touched by more than one agent. It lists the prior personas (name + role) and tells the new agent: "Earlier turns were produced by other personas. Read them as context, but answer in your own voice as the current persona." Without this, switching from Atlas (Operator) to Pythia (Researcher) left Pythia answering as if she'd produced Atlas's prior turns. Tracked via two-trigger useEffect: - On agent change with messages already in the thread, the prior agent gets locked into history. - On stream finish, the current active agent gets added (it just produced a turn). Cleared with the conversation. 2. UI-attribution fix Each assistant turn now records which agent produced it (messageAgents map: index -> Agent). The row signature in MessageRow now reads that stamped agent rather than always echoing the currently-active one. Switching agents mid-thread no longer retroactively re-attributes prior responses. Both maps are wiped by Clear conversation alongside the live snapshot and initialLive ref, so a fresh thread starts truly fresh. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/routes/ai.tsx | 103 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/app/routes/ai.tsx b/app/routes/ai.tsx index 343b66e..402e15e 100644 --- a/app/routes/ai.tsx +++ b/app/routes/ai.tsx @@ -363,10 +363,48 @@ function ChatSurface({ const persona = activeAgent ? `Active persona: ${activeAgent.name} — ${activeAgent.role}\n${activeAgent.prompt}` : "" + + // Track every agent that has produced turns in the current conversation. + // When the operator switches mid-thread, we augment the system prompt so + // the new agent knows it's stepping into a transcript started by another + // persona — without that note it answers as if it produced every prior + // turn itself, which is jarring. + const [agentHistory, setAgentHistory] = useState>( + () => new Map(), + ) + const prevAgentRef = useRef(activeAgent) + + // Per-message agent attribution: which agent produced the assistant + // message at each index. Populated when a turn finishes streaming, used + // in MessageRow to show the right name in the signature line. Survives + // until the conversation is cleared. + const [messageAgents, setMessageAgents] = useState>( + () => new Map(), + ) + + // Hand-off prompt — only emitted when this conversation has been touched + // by more than one agent. Lists prior personas the new agent might see in + // the transcript. + const handoffNote = useMemo(() => { + if (agentHistory.size <= 1) return "" + if (!activeAgent || !agentHistory.has(activeAgent.id)) return "" + const others = [...agentHistory.values()].filter((a) => a.id !== activeAgent.id) + if (others.length === 0) return "" + const list = others + .map((a) => `• ${a.name} (${a.role})`) + .join("\n") + return [ + "PRIOR HAND-OFF:", + "Earlier turns in this conversation were produced by other agent personas. Their responses appear in the transcript as assistant turns. Read them as context — they reflect a different voice and may have different style or focus — but answer the next message in your own voice as the current persona. Don't re-introduce yourself unless the user asks who they're talking to.", + `Prior personas in this thread:\n${list}`, + ].join("\n\n") + }, [agentHistory, activeAgent]) + const systemPrompt = [ "You are the operator's assistant inside Arcadia Admin. Be precise and direct. You have native function tools attached to this conversation — call them whenever the user asks about live platform state (counts, statuses, listings, lookups). Never invent tenant slugs, user counts, or statuses; if you need data, call a tool.", ARCADIA_KNOWLEDGE, persona, + handoffNote, formatAdminContextForPrompt(), ] .filter(Boolean) @@ -402,6 +440,8 @@ function ChatSurface({ initialLive.current = [] clearLive() setMessages([]) + setAgentHistory(new Map()) + setMessageAgents(new Map()) }, [setMessages]) // Auto tool-loop using native function calls. Reads run automatically; @@ -410,6 +450,48 @@ function ChatSurface({ const toolIterationsRef = useRef(0) const processedTurnRef = useRef(-1) const prevStreamingRef = useRef(isStreaming) + + // Maintain agent-history. Two triggers: + // 1. When a turn finishes streaming and at least one user/assistant + // pair exists, the *current* active agent has demonstrably been + // involved — add it. + // 2. When the operator switches the active agent and there are already + // messages in the thread, the *previous* agent was the one talking + // until that moment — add it (the new one will be added on its + // first turn finish). + // Also reset everything when the conversation is cleared. + useEffect(() => { + if (messages.length === 0) { + // Fresh thread — drop any stale history so empty-state behaves. + if (agentHistory.size > 0) setAgentHistory(new Map()) + prevAgentRef.current = activeAgent + return + } + const prev = prevAgentRef.current + if (prev && prev.id !== activeAgent?.id) { + // Operator just switched. Lock in the prior agent so the new one + // sees it in the hand-off note. + setAgentHistory((m) => { + if (m.has(prev.id)) return m + const next = new Map(m) + next.set(prev.id, prev) + return next + }) + } + prevAgentRef.current = activeAgent + // Also add the current agent if it has just produced something. + if (activeAgent && !agentHistory.has(activeAgent.id) && !isStreaming) { + const last = messages[messages.length - 1] + if (last?.role === "assistant" && last.content.trim()) { + setAgentHistory((m) => { + if (m.has(activeAgent.id)) return m + const next = new Map(m) + next.set(activeAgent.id, activeAgent) + return next + }) + } + } + }, [activeAgent, messages, isStreaming, agentHistory]) const MAX_TOOL_ITERATIONS = 3 const [pendingConfirm, setPendingConfirm] = useState<{ /** Message index that emitted the write calls. */ @@ -427,6 +509,20 @@ function ChatSurface({ if (lastIdx < 0) return const last = messages[lastIdx] if (last.role !== "assistant") return + + // Stamp the agent that produced this turn so the UI signature is + // accurate even after the operator switches personas later. Stamps + // the *current* activeAgent — by definition the producer of the + // turn that just finished. + if (activeAgent) { + setMessageAgents((m) => { + if (m.get(lastIdx)?.id === activeAgent.id) return m + const next = new Map(m) + next.set(lastIdx, activeAgent) + return next + }) + } + if (processedTurnRef.current === lastIdx) return processedTurnRef.current = lastIdx const calls = last.toolCalls ?? [] @@ -782,7 +878,12 @@ function ChatSurface({ content={m.content} toolCalls={m.toolCalls} turnNum={i + 1} - agentName={activeAgent?.name ?? "Atlas"} + // Use the agent stamped on this index when known, fall + // back to the active agent (covers the live stream + // before the post-stream effect fires). + agentName={ + messageAgents.get(i)?.name ?? activeAgent?.name ?? "Atlas" + } timestamp={clockLabel} /> {calls.length > 0 && (