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 && (