Merge pull request #233 from ggozad/feat/agui-state-key

Add state_key parameter for keyed AG-UI state emission
This commit is contained in:
Yiorgis Gozadinos 2026-01-14 17:26:21 +02:00 committed by GitHub
commit 05be72d773
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 4526 additions and 2 deletions

View file

@ -3,6 +3,9 @@
### Added ### Added
- **Keyed State Emission for Chat Agent**: New `state_key` parameter in `ChatDeps` for namespaced AG-UI state snapshots
- When set, tools emit `{state_key: snapshot}` instead of bare state, enabling state merging when multiple agents share state
- Default `None` preserves backwards compatibility (bare state emission)
- **Page Image Generation Control**: New `generate_page_images` option in `ConversionOptions` to control PDF page image extraction - **Page Image Generation Control**: New `generate_page_images` option in `ConversionOptions` to control PDF page image extraction
- `generate_page_images: bool = True` - Enable/disable rendered page images (used by `visualize_chunk()`) - `generate_page_images: bool = True` - Enable/disable rendered page images (used by `visualize_chunk()`)
- Works with both `docling-local` and `docling-serve` converters - Works with both `docling-local` and `docling-serve` converters

View file

@ -102,13 +102,17 @@ def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]:
line += f"\n {snippet}" line += f"\n {snippet}"
result_lines.append(line) result_lines.append(line)
snapshot = new_state.model_dump()
if ctx.deps.state_key:
snapshot = {ctx.deps.state_key: snapshot}
return ToolReturn( return ToolReturn(
return_value=f"Found {len(results)} results:\n\n" return_value=f"Found {len(results)} results:\n\n"
+ "\n\n".join(result_lines), + "\n\n".join(result_lines),
metadata=[ metadata=[
StateSnapshotEvent( StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT, type=EventType.STATE_SNAPSHOT,
snapshot=new_state.model_dump(), snapshot=snapshot,
) )
], ],
) )
@ -241,12 +245,16 @@ def create_chat_agent(config: AppConfig) -> Agent[ChatDeps, str]:
citation_refs = " ".join(f"[{i + 1}]" for i in range(len(citation_infos))) citation_refs = " ".join(f"[{i + 1}]" for i in range(len(citation_infos)))
answer_text = f"{answer_text}\n\nSources: {citation_refs}" answer_text = f"{answer_text}\n\nSources: {citation_refs}"
snapshot = new_state.model_dump()
if ctx.deps.state_key:
snapshot = {ctx.deps.state_key: snapshot}
return ToolReturn( return ToolReturn(
return_value=answer_text, return_value=answer_text,
metadata=[ metadata=[
StateSnapshotEvent( StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT, type=EventType.STATE_SNAPSHOT,
snapshot=new_state.model_dump(), snapshot=snapshot,
) )
], ],
) )

View file

@ -159,6 +159,7 @@ class ChatDeps:
config: AppConfig config: AppConfig
search_results: list[SearchResult] | None = None search_results: list[SearchResult] | None = None
session_state: ChatSessionState | None = None session_state: ChatSessionState | None = None
state_key: str | None = None
@dataclass @dataclass

View file

@ -40,6 +40,28 @@ def test_chat_deps_initialization(temp_db_path):
client.close() client.close()
def test_chat_deps_with_state_key(temp_db_path):
"""Test ChatDeps can be initialized with state_key for keyed state emission."""
client = HaikuRAG(temp_db_path, create=True)
deps = ChatDeps(client=client, config=Config, state_key="my_state")
assert deps.client is client
assert deps.config is Config
assert deps.state_key == "my_state"
client.close()
def test_chat_deps_state_key_default_none(temp_db_path):
"""Test ChatDeps state_key defaults to None."""
client = HaikuRAG(temp_db_path, create=True)
deps = ChatDeps(client=client, config=Config)
assert deps.state_key is None
client.close()
def test_chat_session_state(): def test_chat_session_state():
"""Test ChatSessionState model.""" """Test ChatSessionState model."""
state = ChatSessionState(session_id="test-session") state = ChatSessionState(session_id="test-session")
@ -265,6 +287,40 @@ async def test_chat_agent_search_tool(allow_model_requests, temp_db_path):
assert len(result.output) > 0 assert len(result.output) > 0
@pytest.mark.asyncio
@pytest.mark.vcr()
async def test_chat_agent_search_with_state_key(allow_model_requests, temp_db_path):
"""Test search tool emits keyed state when state_key is set."""
async with HaikuRAG(temp_db_path, create=True) as client:
await client.create_document(
content=DOCLAYNET_CLASS_LABELS,
uri="doclaynet-labels",
title="DocLayNet Class Labels",
)
await client.create_document(
content=DOCLAYNET_ANNOTATION,
uri="doclaynet-annotation",
title="DocLayNet Annotation",
)
agent = create_chat_agent(Config)
session_state = ChatSessionState(session_id="test-search")
deps = ChatDeps(
client=client,
config=Config,
session_state=session_state,
state_key="haiku_rag",
)
result = await agent.run(
"Search for documents about class labels",
deps=deps,
)
assert result.output is not None
assert len(result.output) > 0
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.vcr() @pytest.mark.vcr()
async def test_chat_agent_search_tool_with_filter(allow_model_requests, temp_db_path): async def test_chat_agent_search_tool_with_filter(allow_model_requests, temp_db_path):
@ -481,6 +537,35 @@ async def test_chat_agent_ask_adds_citations(allow_model_requests, temp_db_path)
assert len(session_state.qa_history) >= 1 assert len(session_state.qa_history) >= 1
@pytest.mark.asyncio
@pytest.mark.vcr()
async def test_chat_agent_ask_with_state_key(allow_model_requests, temp_db_path):
"""Test ask tool emits keyed state when state_key is set."""
async with HaikuRAG(temp_db_path, create=True) as client:
await client.create_document(
content=DOCLAYNET_CLASS_LABELS,
uri="doclaynet-labels",
title="DocLayNet Class Labels",
)
agent = create_chat_agent(Config)
session_state = ChatSessionState(session_id="test-ask-keyed")
deps = ChatDeps(
client=client,
config=Config,
session_state=session_state,
state_key="haiku_rag",
)
result = await agent.run(
"What is the highest count class in the DocLayNet dataset?",
deps=deps,
)
assert result.output is not None
assert len(session_state.qa_history) >= 1
def test_fifo_limit_enforcement(): def test_fifo_limit_enforcement():
"""Test that FIFO limit enforcement logic works correctly. """Test that FIFO limit enforcement logic works correctly.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long