Fix state delta computation by avoiding mutation of original state
This commit is contained in:
parent
c27dde2497
commit
b5aeefb10f
3 changed files with 79 additions and 114 deletions
|
|
@ -80,30 +80,30 @@ async def stream_chat(request: Request) -> Response:
|
||||||
run_input = AGUIAdapter.build_run_input(body)
|
run_input = AGUIAdapter.build_run_input(body)
|
||||||
|
|
||||||
# Restore session state from incoming AG-UI state (look under namespaced key)
|
# Restore session state from incoming AG-UI state (look under namespaced key)
|
||||||
initial_qa_history: list[QAResponse] = []
|
session_state: ChatSessionState | None = None
|
||||||
session_id: str | None = None
|
|
||||||
document_filter: list[str] = []
|
|
||||||
initial_context: str | None = None
|
|
||||||
state = getattr(run_input, "state", None)
|
state = getattr(run_input, "state", None)
|
||||||
if state:
|
if state and AGUI_STATE_KEY in state:
|
||||||
chat_state = state.get(AGUI_STATE_KEY, state)
|
chat_state = state[AGUI_STATE_KEY]
|
||||||
if "qa_history" in chat_state:
|
if chat_state and chat_state.get("session_id"):
|
||||||
initial_qa_history = [
|
# Only restore state if client has a session_id (not first request)
|
||||||
QAResponse(**qa) for qa in chat_state.get("qa_history", [])
|
# This ensures first request gets a full snapshot with generated UUID
|
||||||
]
|
# NOTE: We intentionally do NOT restore session_context from the client.
|
||||||
session_id = chat_state.get("session_id")
|
# The server maintains session_context via background summarization tasks,
|
||||||
document_filter = chat_state.get("document_filter", [])
|
# and the agent fetches it from the server-side cache (get_cached_session_context).
|
||||||
initial_context = chat_state.get("initial_context")
|
session_state = ChatSessionState(
|
||||||
|
session_id=chat_state["session_id"],
|
||||||
|
qa_history=[
|
||||||
|
QAResponse(**qa) for qa in chat_state.get("qa_history", [])
|
||||||
|
],
|
||||||
|
document_filter=chat_state.get("document_filter", []),
|
||||||
|
initial_context=chat_state.get("initial_context"),
|
||||||
|
citation_registry=chat_state.get("citation_registry", {}),
|
||||||
|
)
|
||||||
|
|
||||||
deps = ChatDeps(
|
deps = ChatDeps(
|
||||||
client=get_client(db_path),
|
client=get_client(db_path),
|
||||||
config=Config,
|
config=Config,
|
||||||
session_state=ChatSessionState(
|
session_state=session_state,
|
||||||
qa_history=initial_qa_history,
|
|
||||||
document_filter=document_filter,
|
|
||||||
initial_context=initial_context,
|
|
||||||
**({"session_id": session_id} if session_id else {}),
|
|
||||||
),
|
|
||||||
state_key=AGUI_STATE_KEY,
|
state_key=AGUI_STATE_KEY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -111,25 +111,7 @@ async def stream_chat(request: Request) -> Response:
|
||||||
adapter = AGUIAdapter(agent=chat_agent, run_input=run_input, accept=accept)
|
adapter = AGUIAdapter(agent=chat_agent, run_input=run_input, accept=accept)
|
||||||
event_stream = adapter.run_stream(deps=deps)
|
event_stream = adapter.run_stream(deps=deps)
|
||||||
|
|
||||||
# Wrap to log state events
|
sse_event_stream = adapter.encode_stream(event_stream)
|
||||||
async def logged_event_stream():
|
|
||||||
async for event in event_stream:
|
|
||||||
event_type = getattr(event, "type", None)
|
|
||||||
if event_type and "state" in str(event_type).lower():
|
|
||||||
delta: list[dict[str, str]] | None = getattr(event, "delta", None)
|
|
||||||
snapshot: dict[str, object] | None = getattr(event, "snapshot", None)
|
|
||||||
if delta is not None:
|
|
||||||
logger.info(f"StateDeltaEvent: {len(delta)} ops")
|
|
||||||
for op in delta[:3]: # Log first 3 ops
|
|
||||||
logger.info(f" {op['op']} {op['path']}")
|
|
||||||
if len(delta) > 3:
|
|
||||||
logger.info(f" ... and {len(delta) - 3} more ops")
|
|
||||||
elif snapshot is not None:
|
|
||||||
snapshot_keys = list(snapshot.keys())
|
|
||||||
logger.info(f"StateSnapshotEvent: keys={snapshot_keys}")
|
|
||||||
yield event
|
|
||||||
|
|
||||||
sse_event_stream = adapter.encode_stream(logged_event_stream())
|
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
sse_event_stream,
|
sse_event_stream,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ interface ChatSessionState {
|
||||||
qa_history: QAResponse[];
|
qa_history: QAResponse[];
|
||||||
session_context: SessionContext | null;
|
session_context: SessionContext | null;
|
||||||
document_filter: string[];
|
document_filter: string[];
|
||||||
|
citation_registry: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AG-UI state is namespaced under AGUI_STATE_KEY
|
// AG-UI state is namespaced under AGUI_STATE_KEY
|
||||||
|
|
@ -404,6 +405,7 @@ function ChatContentInner() {
|
||||||
qa_history: [],
|
qa_history: [],
|
||||||
session_context: null,
|
session_context: null,
|
||||||
document_filter: [],
|
document_filter: [],
|
||||||
|
citation_registry: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -429,6 +431,8 @@ function ChatContentInner() {
|
||||||
qa_history: agentState?.[AGUI_STATE_KEY]?.qa_history ?? [],
|
qa_history: agentState?.[AGUI_STATE_KEY]?.qa_history ?? [],
|
||||||
session_context: agentState?.[AGUI_STATE_KEY]?.session_context ?? null,
|
session_context: agentState?.[AGUI_STATE_KEY]?.session_context ?? null,
|
||||||
document_filter: selected,
|
document_filter: selected,
|
||||||
|
citation_registry:
|
||||||
|
agentState?.[AGUI_STATE_KEY]?.citation_registry ?? {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -445,6 +449,8 @@ function ChatContentInner() {
|
||||||
qa_history: agentState?.[AGUI_STATE_KEY]?.qa_history ?? [],
|
qa_history: agentState?.[AGUI_STATE_KEY]?.qa_history ?? [],
|
||||||
session_context: agentState?.[AGUI_STATE_KEY]?.session_context ?? null,
|
session_context: agentState?.[AGUI_STATE_KEY]?.session_context ?? null,
|
||||||
document_filter: agentState?.[AGUI_STATE_KEY]?.document_filter ?? [],
|
document_filter: agentState?.[AGUI_STATE_KEY]?.document_filter ?? [],
|
||||||
|
citation_registry:
|
||||||
|
agentState?.[AGUI_STATE_KEY]?.citation_registry ?? {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -116,12 +116,19 @@ def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]:
|
||||||
if not results:
|
if not results:
|
||||||
return ToolReturn(return_value="No results found.")
|
return ToolReturn(return_value="No results found.")
|
||||||
|
|
||||||
# Build citation infos using stable registry indices
|
# Copy session state to work with (avoids mutating original for delta computation)
|
||||||
|
new_state = (
|
||||||
|
ctx.deps.session_state.model_copy(deep=True)
|
||||||
|
if ctx.deps.session_state
|
||||||
|
else ChatSessionState()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build citation infos using the copy's registry
|
||||||
citation_infos = []
|
citation_infos = []
|
||||||
for r in results:
|
for r in results:
|
||||||
chunk_id = r.chunk_id or ""
|
chunk_id = r.chunk_id or ""
|
||||||
if ctx.deps.session_state is not None and chunk_id:
|
if chunk_id:
|
||||||
index = ctx.deps.session_state.get_or_assign_index(chunk_id)
|
index = new_state.get_or_assign_index(chunk_id)
|
||||||
else:
|
else:
|
||||||
index = len(citation_infos) + 1
|
index = len(citation_infos) + 1
|
||||||
citation_infos.append(
|
citation_infos.append(
|
||||||
|
|
@ -137,26 +144,10 @@ def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build new state with citations and registry
|
# Update new_state with citations and fresh session_context
|
||||||
session_id = ctx.deps.session_state.session_id if ctx.deps.session_state else ""
|
new_state.citations = citation_infos
|
||||||
new_state = ChatSessionState(
|
if new_state.session_id:
|
||||||
session_id=session_id,
|
new_state.session_context = get_cached_session_context(new_state.session_id)
|
||||||
citations=citation_infos,
|
|
||||||
qa_history=(
|
|
||||||
ctx.deps.session_state.qa_history if ctx.deps.session_state else []
|
|
||||||
),
|
|
||||||
session_context=get_cached_session_context(session_id)
|
|
||||||
if session_id
|
|
||||||
else None,
|
|
||||||
document_filter=(
|
|
||||||
ctx.deps.session_state.document_filter if ctx.deps.session_state else []
|
|
||||||
),
|
|
||||||
citation_registry=(
|
|
||||||
ctx.deps.session_state.citation_registry
|
|
||||||
if ctx.deps.session_state
|
|
||||||
else {}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return detailed results for the agent to present
|
# Return detailed results for the agent to present
|
||||||
result_lines = []
|
result_lines = []
|
||||||
|
|
@ -276,14 +267,17 @@ def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]:
|
||||||
|
|
||||||
result = await graph.run(state=state, deps=deps)
|
result = await graph.run(state=state, deps=deps)
|
||||||
|
|
||||||
# Build citation infos using stable registry indices
|
# Copy session state to work with (avoids mutating original for delta computation)
|
||||||
|
new_state = (
|
||||||
|
ctx.deps.session_state.model_copy(deep=True)
|
||||||
|
if ctx.deps.session_state
|
||||||
|
else ChatSessionState()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build citation infos using the copy's registry
|
||||||
citation_infos = []
|
citation_infos = []
|
||||||
for c in result.citations:
|
for c in result.citations:
|
||||||
# Use registry for stable indices across calls
|
index = new_state.get_or_assign_index(c.chunk_id)
|
||||||
if ctx.deps.session_state is not None:
|
|
||||||
index = ctx.deps.session_state.get_or_assign_index(c.chunk_id)
|
|
||||||
else:
|
|
||||||
index = len(citation_infos) + 1
|
|
||||||
citation_infos.append(
|
citation_infos.append(
|
||||||
Citation(
|
Citation(
|
||||||
index=index,
|
index=index,
|
||||||
|
|
@ -297,54 +291,37 @@ def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Accumulate Q&A in session state with full citation metadata
|
# Add Q&A to the copy's history
|
||||||
if ctx.deps.session_state is not None:
|
qa_response = QAResponse(
|
||||||
qa_response = QAResponse(
|
question=question,
|
||||||
question=question,
|
answer=result.answer,
|
||||||
answer=result.answer,
|
confidence=result.confidence,
|
||||||
confidence=result.confidence,
|
|
||||||
citations=citation_infos,
|
|
||||||
)
|
|
||||||
ctx.deps.session_state.qa_history.append(qa_response)
|
|
||||||
# Enforce FIFO limit
|
|
||||||
if len(ctx.deps.session_state.qa_history) > MAX_QA_HISTORY:
|
|
||||||
ctx.deps.session_state.qa_history = ctx.deps.session_state.qa_history[
|
|
||||||
-MAX_QA_HISTORY:
|
|
||||||
]
|
|
||||||
|
|
||||||
# Spawn background task to update session context
|
|
||||||
# Cancel any previous summarization for this session
|
|
||||||
if session_id in _summarization_tasks:
|
|
||||||
_summarization_tasks[session_id].cancel()
|
|
||||||
|
|
||||||
task = asyncio.create_task(
|
|
||||||
_update_context_background(
|
|
||||||
qa_history=list(ctx.deps.session_state.qa_history),
|
|
||||||
config=ctx.deps.config,
|
|
||||||
session_state=ctx.deps.session_state,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
_summarization_tasks[session_id] = task
|
|
||||||
task.add_done_callback(lambda t: _summarization_tasks.pop(session_id, None))
|
|
||||||
|
|
||||||
# Build new state with citations, qa_history, and registry
|
|
||||||
new_state = ChatSessionState(
|
|
||||||
session_id=session_id,
|
|
||||||
citations=citation_infos,
|
citations=citation_infos,
|
||||||
qa_history=(
|
)
|
||||||
ctx.deps.session_state.qa_history if ctx.deps.session_state else []
|
new_state.qa_history.append(qa_response)
|
||||||
),
|
# Enforce FIFO limit
|
||||||
session_context=get_cached_session_context(session_id)
|
if len(new_state.qa_history) > MAX_QA_HISTORY:
|
||||||
if session_id
|
new_state.qa_history = new_state.qa_history[-MAX_QA_HISTORY:]
|
||||||
else None,
|
|
||||||
document_filter=(
|
# Update citations and session_context
|
||||||
ctx.deps.session_state.document_filter if ctx.deps.session_state else []
|
new_state.citations = citation_infos
|
||||||
),
|
if new_state.session_id:
|
||||||
citation_registry=(
|
new_state.session_context = get_cached_session_context(new_state.session_id)
|
||||||
ctx.deps.session_state.citation_registry
|
|
||||||
if ctx.deps.session_state
|
# Spawn background task to update session context
|
||||||
else {}
|
if new_state.session_id in _summarization_tasks:
|
||||||
),
|
_summarization_tasks[new_state.session_id].cancel()
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
_update_context_background(
|
||||||
|
qa_history=list(new_state.qa_history),
|
||||||
|
config=ctx.deps.config,
|
||||||
|
session_state=new_state,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_summarization_tasks[new_state.session_id] = task
|
||||||
|
task.add_done_callback(
|
||||||
|
lambda t, sid=new_state.session_id: _summarization_tasks.pop(sid, None)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Format answer with citation references using stable indices
|
# Format answer with citation references using stable indices
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue