Skip to content

Commit 34fd794

Browse files
committed
bug #838 [Agent] Fix sources metadata unavailable during streaming (Copilot)
This PR was squashed before being merged into the main branch. Discussion ---------- [Agent] Fix sources metadata unavailable during streaming | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | Docs? | no | Issues | Fix #833 | License | MIT - [x] Analyze the issue with sources metadata being null during streaming - [x] Identify the root cause in ToolboxStreamResult - [x] Implement fix to propagate metadata from inner result to outer result - [x] Add test for streaming with sources - [x] Fix trailing whitespace in test (code quality) - [x] Fix "yield from" error with non-iterable content - [x] Simplify content type check for better maintainability - [x] Improve code comments and comparison consistency - [x] Add safety check for non-iterable objects - [x] Revert unnecessary .gitignore change - [x] Apply Symfony code style conventions ## Summary Fixed issue where sources metadata was inaccessible when using streaming with the Agent. The problem was that metadata was set on the inner result but never propagated to the outer StreamResult object. **Changes:** - Modified `StreamResult::getContent()` to capture and propagate metadata from inner result - Added comprehensive type checking to safely handle all content types - Added test `testSourcesEndUpInResultMetadataWithStreaming()` to verify the fix works correctly - Applied Symfony code style conventions (Yoda conditions, fully qualified function names) <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>[Agent] Unable to access sources metadata with streaming</issue_title> > <issue_description>I am implementing the Symfony AI Agent in combination with the [ai-sdk](https://ai-sdk.dev/getting-started). I want to display the used sources in the UI, so I want to dispatch these in an event stream using the ai-sdk Data Stream Protocol. However, during streaming, the metadata seems to reset and sources are always `null`. Without the stream option this works fine. > > Should this be done in a different order? The sources are present at some point, but seem to reset while actually streaming the content back. > > This is basically all that I am doing: > > > ```php > public function __invoke(ChatRequest $request): StreamedResponse > { > $platform = PlatformFactory::create(env('OPENAI_API_KEY'), HttpClient::create()); > > $toolBox = new ToolBox([ > new RandomTool(), > ]); > $processor = new AgentProcessor($toolBox, includeSources: true); > $agent = new Agent($platform, 'gpt-4o-mini', [$processor], [$processor]); > > $messages = new MessageBag(); > foreach ($request->messages() as $message) { > foreach ($message['parts'] as $part) { > if ($part['type'] !== 'text') { > continue; > } > $messages->add(Message::ofUser($part['text'])); > } > } > > $result = $agent->call($messages, [ > 'stream' => true > ]); > > $response = new StreamedResponse(function () use ($result) { > $messageId = 'msg_' . bin2hex(random_bytes(16)); > $textBlockId = 'text_' . bin2hex(random_bytes(16)); > > $this->sendSSE(['type' => 'start', 'messageId' => $messageId]); > $this->sendSSE(['type' => 'text-start', 'id' => $textBlockId]); > > foreach ($result->getContent() as $chunk) { > $text = (string)$chunk; > if ($text !== '') { > $this->sendSSE([ > 'type' => 'text-delta', > 'id' => $textBlockId, > 'delta' => $text > ]); > } > } > > $this->sendSSE(['type' => 'text-end', 'id' => $textBlockId]); > > // This is always null > $sources = $result->getMetadata()->get('sources', []); > foreach ($sources as $source) { > if (!$source instanceof Source) { > continue; > } > $this->sendSSE([ > 'type' => 'source-url', > 'sourceId' => $source->getReference(), > 'url' => $source->getReference(), > 'title' => $source->getName() > ]); > } > > $this->sendSSE(['type' => 'finish']); > > echo "data: [DONE]\n\n"; > flush(); > }); > > > $response->headers->set('Content-Type', 'text/event-stream'); > $response->headers->set('Cache-Control', 'no-cache'); > $response->headers->set('Connection', 'keep-alive'); > $response->headers->set('X-Accel-Buffering', 'no'); > $response->headers->set('x-vercel-ai-ui-message-stream', 'v1'); > > return $response; > } > > private function sendSSE(array $data): void > { > echo 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n"; > flush(); > } > ``` > > > </issue_description> > > <agent_instructions>Try to fix this issue. do it as minimal as possible</agent_instructions> > > ## Comments on the Issue (you are `@copilot` in this section) > > <comments> > <comment_new><author>`@chr`-hertel</author><body> > Sounds like a legit bug to me - didn't test that yet as well. PR ofc welcome :)</body></comment_new> > </comments> > </details> - Fixes #833 <!-- START COPILOT CODING AGENT TIPS --> --- ✨ Let Copilot coding agent [set things up for you](https://github.com/symfony/ai/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo. Commits ------- cfdc2d8 [Agent] Fix sources metadata unavailable during streaming
2 parents c840dad + cfdc2d8 commit 34fd794

File tree

2 files changed

+65
-1
lines changed

2 files changed

+65
-1
lines changed

src/agent/src/Toolbox/StreamResult.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,22 @@ public function getContent(): \Generator
3131
$streamedResult = '';
3232
foreach ($this->generator as $value) {
3333
if ($value instanceof ToolCallResult) {
34-
yield from ($this->handleToolCallsCallback)($value, Message::ofAssistant($streamedResult))->getContent();
34+
$innerResult = ($this->handleToolCallsCallback)($value, Message::ofAssistant($streamedResult));
35+
36+
// Propagate metadata from inner result to this result
37+
foreach ($innerResult->getMetadata()->all() as $key => $metadataValue) {
38+
$this->getMetadata()->add($key, $metadataValue);
39+
}
40+
41+
$content = $innerResult->getContent();
42+
// Strings are iterable in PHP but yield from would iterate character-by-character.
43+
// We need to yield the complete string as a single value to preserve streaming behavior.
44+
// null should also be yielded as-is.
45+
if (\is_string($content) || null === $content || !is_iterable($content)) {
46+
yield $content;
47+
} else {
48+
yield from $content;
49+
}
3550

3651
break;
3752
}

src/agent/tests/Toolbox/AgentProcessorTest.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use Symfony\AI\Platform\PlatformInterface;
2727
use Symfony\AI\Platform\Result\DeferredResult;
2828
use Symfony\AI\Platform\Result\InMemoryRawResult;
29+
use Symfony\AI\Platform\Result\StreamResult as GenericStreamResult;
2930
use Symfony\AI\Platform\Result\TextResult;
3031
use Symfony\AI\Platform\Result\ToolCall;
3132
use Symfony\AI\Platform\Result\ToolCallResult;
@@ -233,4 +234,52 @@ public function testSourcesGetCollectedAcrossConsecutiveToolCalls()
233234
$this->assertCount(2, $metadata->get('sources'));
234235
$this->assertSame([$source1, $source2], $metadata->get('sources'));
235236
}
237+
238+
public function testSourcesEndUpInResultMetadataWithStreaming()
239+
{
240+
$toolCall = new ToolCall('call_1234', 'tool_sources', ['arg1' => 'value1']);
241+
$source1 = new Source('Relevant Article 1', 'http://example.com/article1', 'Content of article about the topic');
242+
$source2 = new Source('Relevant Article 2', 'http://example.com/article2', 'More content of article about the topic');
243+
$toolbox = $this->createMock(ToolboxInterface::class);
244+
$toolbox
245+
->expects($this->once())
246+
->method('execute')
247+
->willReturn(new ToolResult($toolCall, 'Response based on the two articles.', [$source1, $source2]));
248+
249+
$messageBag = new MessageBag();
250+
251+
// Create a generator that yields chunks and then a ToolCallResult
252+
$generator = (function () use ($toolCall) {
253+
yield 'chunk1';
254+
yield 'chunk2';
255+
yield new ToolCallResult($toolCall);
256+
})();
257+
258+
$result = new GenericStreamResult($generator);
259+
260+
$agent = $this->createMock(AgentInterface::class);
261+
$agent
262+
->expects($this->once())
263+
->method('call')
264+
->willReturn(new TextResult('Final response based on the two articles.'));
265+
266+
$processor = new AgentProcessor($toolbox, includeSources: true);
267+
$processor->setAgent($agent);
268+
269+
$output = new Output('gpt-4', $result, $messageBag);
270+
271+
$processor->processOutput($output);
272+
273+
// Consume the stream
274+
$content = '';
275+
foreach ($output->getResult()->getContent() as $chunk) {
276+
$content .= $chunk;
277+
}
278+
279+
// After consuming the stream, metadata should be available
280+
$metadata = $output->getResult()->getMetadata();
281+
$this->assertTrue($metadata->has('sources'));
282+
$this->assertCount(2, $metadata->get('sources'));
283+
$this->assertSame([$source1, $source2], $metadata->get('sources'));
284+
}
236285
}

0 commit comments

Comments
 (0)