Skip to content

Commit f69db2a

Browse files
Implement citation handling improvements in response handlers and orchestration manager
- Add functions to manage streaming citation buffers and clean citations. - Update orchestration manager to clear citation buffers and clean final text. - Enhance unit tests for citation cleaning and streaming callbacks.
1 parent ee285e8 commit f69db2a

4 files changed

Lines changed: 121 additions & 5 deletions

File tree

src/backend/callbacks/response_handlers.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,38 @@
1717

1818
logger = logging.getLogger(__name__)
1919

20+
_stream_citation_buffers: dict[tuple[str, str], str] = {}
21+
22+
23+
def clear_streaming_citation_buffers(
24+
user_id: str,
25+
agent_id: str | None = None,
26+
) -> None:
27+
"""Discard partial citation markers retained between streaming chunks."""
28+
for key in [
29+
key
30+
for key in _stream_citation_buffers
31+
if key[0] == user_id and (agent_id is None or key[1] == agent_id)
32+
]:
33+
_stream_citation_buffers.pop(key, None)
34+
35+
36+
def _split_trailing_partial_citation(text: str) -> tuple[str, str]:
37+
"""Hold a trailing citation prefix until a later chunk completes it."""
38+
for opener, closer in (("[", "]"), ("【", "】")):
39+
open_index = text.rfind(opener)
40+
if open_index == -1 or text.find(closer, open_index) != -1:
41+
continue
42+
43+
candidate = text[open_index + 1:]
44+
if not candidate.strip() or re.fullmatch(
45+
r"\s*\d+(?:\s*:\s*\d*)?(?:\s*[|†]?\s*[a-zA-Z]*)?",
46+
candidate,
47+
):
48+
return text[:open_index], text[open_index:]
49+
50+
return text, ""
51+
2052

2153
def format_agent_display_name(raw_name: str) -> str:
2254
"""Convert raw agent IDs (e.g. 'HRHelperAgent', 'hr_helper_agent') to
@@ -61,7 +93,12 @@ def clean_citations(text: str) -> str:
6193
"""Remove citation markers from agent responses while preserving formatting."""
6294
if not text:
6395
return text
64-
text = re.sub(r'\[\d+:\d+\|source\]', '', text)
96+
text = re.sub(
97+
r'\[\s*\d+\s*:\s*\d+\s*[|†]\s*source\s*\]',
98+
'',
99+
text,
100+
flags=re.IGNORECASE,
101+
)
65102
text = re.sub(r'\[\s*source\s*\]', '', text, flags=re.IGNORECASE)
66103
text = re.sub(r'\[\d+\]', '', text)
67104
text = re.sub(r'【[^】]*】', '', text)
@@ -164,7 +201,17 @@ async def streaming_agent_response_callback(
164201
collected.append(str(txt))
165202
chunk_text = "".join(collected) if collected else ""
166203

167-
cleaned = clean_citations(chunk_text or "")
204+
buffer_key = (user_id, agent_id)
205+
combined = _stream_citation_buffers.pop(buffer_key, "") + (chunk_text or "")
206+
207+
if is_final:
208+
emittable, _ = _split_trailing_partial_citation(combined)
209+
else:
210+
emittable, held_back = _split_trailing_partial_citation(combined)
211+
if held_back:
212+
_stream_citation_buffers[buffer_key] = held_back
213+
214+
cleaned = clean_citations(emittable)
168215

169216
contents = getattr(update, "contents", []) or []
170217
tool_calls = _extract_tool_calls_from_contents(contents)

src/backend/orchestration/orchestration_manager.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
MagenticPlanReviewRequest)
1818
from agents.agent_factory import AgentFactory
1919
from callbacks.response_handlers import (agent_response_callback,
20+
clean_citations,
21+
clear_streaming_citation_buffers,
2022
format_agent_display_name,
2123
streaming_agent_response_callback)
2224
from common.config.app_config import config
@@ -395,6 +397,7 @@ async def run_orchestration(self, user_id: str, input_task) -> None:
395397
final_output_ref: list = [None]
396398
orchestrator_chunks: list[str] = []
397399
current_streaming_agent_ref: list = [None]
400+
clear_streaming_citation_buffers(user_id)
398401

399402
# Collect participant names for plan conversion
400403
participant_names = [
@@ -479,6 +482,9 @@ async def run_orchestration(self, user_id: str, input_task) -> None:
479482
# accumulated orchestrator streaming chunks.
480483
final_text = final_output_ref[0] or "".join(orchestrator_chunks)
481484

485+
# The manager's final answer bypasses the participant callbacks.
486+
final_text = clean_citations(final_text)
487+
482488
# Repair collapsed markdown tables before rendering (Bug 47810).
483489
final_text = _normalize_markdown_tables(final_text)
484490

@@ -548,6 +554,7 @@ async def run_orchestration(self, user_id: str, input_task) -> None:
548554
raise
549555

550556
finally:
557+
clear_streaming_citation_buffers(user_id)
551558
# Clean up MCP connections to avoid noisy cross-task
552559
# RuntimeError from anyio when async generators are GC'd.
553560
await self._cleanup_workflow_mcp(user_id)
@@ -1131,6 +1138,7 @@ async def _process_event_stream(
11311138
if isinstance(msg, Message) and msg.text:
11321139
final_output_ref[0] = msg.text
11331140
else:
1141+
clear_streaming_citation_buffers(user_id, agent_id)
11341142
for msg in event.data:
11351143
if isinstance(msg, Message) and msg.text:
11361144
try:

src/tests/backend/callbacks/test_response_handlers.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ def __init__(self, text="", role="assistant", author_name=""):
132132
from backend.callbacks.response_handlers import (
133133
_extract_tool_calls_from_contents, _is_function_call_item,
134134
agent_response_callback, clean_citations,
135+
clear_streaming_citation_buffers,
135136
format_agent_display_name,
136137
streaming_agent_response_callback)
137138

@@ -166,6 +167,18 @@ def test_clean_citations_numeric_source(self):
166167
expected = "This is text with citations."
167168
assert clean_citations(text) == expected
168169

170+
def test_clean_citations_foundry_numeric_source(self):
171+
"""Test cleaning the Azure Foundry [1:2†source] citation format."""
172+
text = "This is text [5:0†source] with citations."
173+
expected = "This is text with citations."
174+
assert clean_citations(text) == expected
175+
176+
def test_clean_citations_foundry_numeric_source_with_spacing(self):
177+
"""Test cleaning Foundry citations with optional spacing and casing."""
178+
text = "This is text [ 5 : 0 † SOURCE ] with citations."
179+
expected = "This is text with citations."
180+
assert clean_citations(text) == expected
181+
169182
def test_clean_citations_source_only(self):
170183
"""Test cleaning [source] format citations."""
171184
text = "Text with [source] citation."
@@ -440,7 +453,7 @@ def test_agent_response_callback_with_chat_message(self, mock_time, mock_create_
440453

441454
# Create an instance of our MockChatMessage
442455
mock_message = MockChatMessage()
443-
mock_message.text = "Test message with citations [1:2|source]"
456+
mock_message.text = "Test message with citations [5:0†source]"
444457
mock_message.author_name = "TestAgent"
445458
mock_message.role = "assistant"
446459

@@ -573,7 +586,7 @@ async def test_streaming_callback_no_user_id(self):
573586
async def test_streaming_callback_with_text(self):
574587
"""Test streaming callback with update that has text."""
575588
mock_update = Mock()
576-
mock_update.text = "Test streaming text [source]"
589+
mock_update.text = "Test streaming text [5:0†source]"
577590
mock_update.contents = []
578591

579592
with patch('backend.callbacks.response_handlers.AgentMessageStreaming') as mock_streaming:
@@ -596,6 +609,24 @@ async def test_streaming_callback_with_text(self):
596609
message_type=WebsocketMessageType.AGENT_MESSAGE_STREAMING
597610
)
598611

612+
@pytest.mark.asyncio
613+
async def test_streaming_callback_cleans_citation_split_across_chunks(self):
614+
"""A split citation marker must never reach the thinking-process UI."""
615+
clear_streaming_citation_buffers("user_456")
616+
first_update = Mock(text="Test streaming text [5:0†sou", contents=[])
617+
second_update = Mock(text="rce] continues.", contents=[])
618+
619+
with patch('backend.callbacks.response_handlers.AgentMessageStreaming') as mock_streaming:
620+
await streaming_agent_response_callback(
621+
"agent_123", first_update, False, user_id="user_456"
622+
)
623+
await streaming_agent_response_callback(
624+
"agent_123", second_update, False, user_id="user_456"
625+
)
626+
627+
assert mock_streaming.call_args_list[0].kwargs["content"] == "Test streaming text "
628+
assert mock_streaming.call_args_list[1].kwargs["content"] == " continues."
629+
599630
@pytest.mark.asyncio
600631
async def test_streaming_callback_no_text_with_contents(self):
601632
"""Test streaming callback when update has no text but has contents with text.

src/tests/backend/orchestration/test_orchestration_manager.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,10 @@ def __init__(self):
217217

218218
sys.modules['callbacks.response_handlers'] = Mock(
219219
agent_response_callback=Mock(),
220+
clean_citations=Mock(
221+
side_effect=lambda text: text.replace("[5:0†source]", "")
222+
),
223+
clear_streaming_citation_buffers=Mock(),
220224
streaming_agent_response_callback=AsyncMock(),
221225
)
222226

@@ -316,6 +320,7 @@ async def get_agents(self, user_id, team_config_input, memory_store):
316320
orchestration_config = sys.modules['orchestration.connection_config'].orchestration_config
317321
agent_response_callback = sys.modules['callbacks.response_handlers'].agent_response_callback
318322
streaming_agent_response_callback = sys.modules['callbacks.response_handlers'].streaming_agent_response_callback
323+
clear_streaming_citation_buffers = sys.modules['callbacks.response_handlers'].clear_streaming_citation_buffers
319324

320325

321326
# =========================================================================
@@ -606,6 +611,7 @@ def setup_method(self):
606611
agent_response_callback.reset_mock()
607612
streaming_agent_response_callback.reset_mock()
608613
streaming_agent_response_callback.side_effect = None
614+
clear_streaming_citation_buffers.reset_mock()
609615
mock_wait_approval.reset_mock()
610616
mock_wait_approval.return_value = MockPlanApprovalResponse(approved=True, m_plan_id="test-plan-id")
611617
mock_convert.reset_mock()
@@ -661,6 +667,31 @@ async def test_given_executor_completed_when_run_then_captures_final_text(self):
661667
sent_message = call_args[0][0]
662668
assert sent_message["data"]["content"] == "Final answer text"
663669

670+
@pytest.mark.asyncio
671+
async def test_given_manager_citation_when_run_then_final_text_is_cleaned(self):
672+
final_msg = MockMessage(text="Final answer [5:0†source]")
673+
events = [
674+
_make_event(
675+
"executor_completed",
676+
data=[final_msg],
677+
executor_id="magentic_orchestrator",
678+
),
679+
]
680+
mock_workflow = Mock()
681+
mock_workflow.run = Mock(return_value=_async_iter(events))
682+
mock_workflow._executors = {}
683+
mock_workflow.executors = {}
684+
mock_workflow.get_executors_list.return_value = []
685+
orchestration_config.get_current_orchestration.return_value = mock_workflow
686+
687+
await OrchestrationManager().run_orchestration(
688+
user_id="user-1",
689+
input_task="do stuff",
690+
)
691+
692+
sent_message = connection_config.send_status_update_async.call_args_list[-1][0][0]
693+
assert sent_message["data"]["content"] == "Final answer "
694+
664695
@pytest.mark.asyncio
665696
async def test_given_agent_completed_event_when_run_then_calls_agent_callback(self):
666697
# Arrange
@@ -1071,4 +1102,3 @@ def test_given_empty_or_none_when_normalized_then_returns_input(self):
10711102

10721103
def test_given_non_table_pipe_line_when_reflowed_then_returns_none(self):
10731104
assert _reflow_collapsed_table_line("a | b | c") is None
1074-

0 commit comments

Comments
 (0)