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:
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user