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) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-02 20:03:08 +10:00
parent a770faf6eb
commit e4ed05b815

View File

@@ -363,10 +363,48 @@ function ChatSurface({
const persona = activeAgent const persona = activeAgent
? `Active persona: ${activeAgent.name}${activeAgent.role}\n${activeAgent.prompt}` ? `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<Map<string, Agent>>(
() => new Map(),
)
const prevAgentRef = useRef<Agent | undefined>(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<Map<number, Agent>>(
() => 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 = [ 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.", "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, ARCADIA_KNOWLEDGE,
persona, persona,
handoffNote,
formatAdminContextForPrompt(), formatAdminContextForPrompt(),
] ]
.filter(Boolean) .filter(Boolean)
@@ -402,6 +440,8 @@ function ChatSurface({
initialLive.current = [] initialLive.current = []
clearLive() clearLive()
setMessages([]) setMessages([])
setAgentHistory(new Map())
setMessageAgents(new Map())
}, [setMessages]) }, [setMessages])
// Auto tool-loop using native function calls. Reads run automatically; // Auto tool-loop using native function calls. Reads run automatically;
@@ -410,6 +450,48 @@ function ChatSurface({
const toolIterationsRef = useRef(0) const toolIterationsRef = useRef(0)
const processedTurnRef = useRef(-1) const processedTurnRef = useRef(-1)
const prevStreamingRef = useRef(isStreaming) 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 MAX_TOOL_ITERATIONS = 3
const [pendingConfirm, setPendingConfirm] = useState<{ const [pendingConfirm, setPendingConfirm] = useState<{
/** Message index that emitted the write calls. */ /** Message index that emitted the write calls. */
@@ -427,6 +509,20 @@ function ChatSurface({
if (lastIdx < 0) return if (lastIdx < 0) return
const last = messages[lastIdx] const last = messages[lastIdx]
if (last.role !== "assistant") return 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 if (processedTurnRef.current === lastIdx) return
processedTurnRef.current = lastIdx processedTurnRef.current = lastIdx
const calls = last.toolCalls ?? [] const calls = last.toolCalls ?? []
@@ -782,7 +878,12 @@ function ChatSurface({
content={m.content} content={m.content}
toolCalls={m.toolCalls} toolCalls={m.toolCalls}
turnNum={i + 1} 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} timestamp={clockLabel}
/> />
{calls.length > 0 && ( {calls.length > 0 && (