Skip to content

Commit eb5d4d5

Browse files
Update src/a2a/utils/artifact.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent dedda6c commit eb5d4d5

6 files changed

Lines changed: 1086 additions & 823 deletions

File tree

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ repos:
3131
# ===============================================
3232
# Python Hooks
3333
# ===============================================
34+
3435
# Ruff for linting and formatting
3536
- repo: https://github.com/astral-sh/ruff-pre-commit
3637
rev: v0.12.0

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ dev = [
117117
"types-protobuf",
118118
"types-requests",
119119
"pre-commit",
120+
120121
"trio",
121122
"uvicorn>=0.35.0",
122123
"pytest-timeout>=2.4.0",

src/a2a/utils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from a2a.utils import proto_utils
44
from a2a.utils.artifact import (
5+
ArtifactStreamer,
56
get_artifact_text,
67
new_artifact,
78
new_data_artifact,
@@ -39,6 +40,7 @@
3940
'AGENT_CARD_WELL_KNOWN_PATH',
4041
'DEFAULT_RPC_URL',
4142
'TransportProtocol',
43+
'ArtifactStreamer',
4244
'append_artifact_to_task',
4345
'are_modalities_compatible',
4446
'build_text_artifact',

src/a2a/utils/artifact.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from google.protobuf.struct_pb2 import Struct, Value
88

9-
from a2a.types.a2a_pb2 import Artifact, Part
9+
from a2a.types.a2a_pb2 import Artifact, Part, TaskArtifactUpdateEvent
1010
from a2a.utils.parts import get_text_parts
1111

1212

@@ -90,3 +90,79 @@ def get_artifact_text(artifact: Artifact, delimiter: str = '\n') -> str:
9090
A single string containing all text content, or an empty string if no text parts are found.
9191
"""
9292
return delimiter.join(get_text_parts(artifact.parts))
93+
94+
95+
class ArtifactStreamer:
96+
"""Helper for streaming text into a single artifact across multiple events.
97+
98+
Creates a stable artifact ID on construction so all chunks reference
99+
the same artifact, enabling proper append semantics per the A2A spec.
100+
101+
Example::
102+
103+
streamer = ArtifactStreamer(context_id, task_id, name='response')
104+
105+
async for chunk in llm.stream(prompt):
106+
await event_queue.enqueue_event(streamer.append(chunk))
107+
108+
await event_queue.enqueue_event(streamer.finalize())
109+
110+
Args:
111+
context_id: The context ID associated with the task.
112+
task_id: The task ID associated with the streaming session.
113+
name: A human-readable name for the artifact.
114+
artifact_id: An explicit artifact ID. If omitted a UUID is generated.
115+
"""
116+
117+
def __init__(
118+
self,
119+
context_id: str,
120+
task_id: str,
121+
name: str = 'response',
122+
artifact_id: str | None = None,
123+
) -> None:
124+
self._context_id = context_id
125+
self._task_id = task_id
126+
self._name = name
127+
self._artifact_id = artifact_id or str(uuid.uuid4())
128+
129+
def append(self, text: str) -> TaskArtifactUpdateEvent:
130+
"""Emit a chunk to be appended to the streaming artifact.
131+
132+
Args:
133+
text: The incremental text content for this chunk.
134+
135+
Returns:
136+
A ``TaskArtifactUpdateEvent`` with ``append=True`` and
137+
``last_chunk=False``.
138+
"""
139+
return TaskArtifactUpdateEvent(
140+
context_id=self._context_id,
141+
task_id=self._task_id,
142+
append=True,
143+
last_chunk=False,
144+
artifact=Artifact(
145+
artifact_id=self._artifact_id,
146+
name=self._name,
147+
parts=[Part(text=text)],
148+
)
149+
)
150+
151+
def finalize(self) -> TaskArtifactUpdateEvent:
152+
"""Signal that the artifact stream is complete.
153+
154+
Returns:
155+
A ``TaskArtifactUpdateEvent`` with ``append=True`` and
156+
``last_chunk=True``.
157+
"""
158+
return TaskArtifactUpdateEvent(
159+
context_id=self._context_id,
160+
task_id=self._task_id,
161+
append=True,
162+
last_chunk=True,
163+
artifact=Artifact(
164+
artifact_id=self._artifact_id,
165+
name=self._name,
166+
parts=[],
167+
)
168+
)

tests/utils/test_artifact.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
from a2a.types.a2a_pb2 import (
99
Artifact,
1010
Part,
11+
TaskArtifactUpdateEvent,
1112
)
1213
from a2a.utils.artifact import (
14+
ArtifactStreamer,
1315
get_artifact_text,
1416
new_artifact,
1517
new_data_artifact,
@@ -157,5 +159,105 @@ def test_get_artifact_text_empty_parts(self):
157159
assert result == ''
158160

159161

162+
class TestArtifactStreamer(unittest.TestCase):
163+
def setUp(self):
164+
self.context_id = 'ctx-123'
165+
self.task_id = 'task-456'
166+
167+
def test_generates_stable_artifact_id(self):
168+
streamer = ArtifactStreamer(self.context_id, self.task_id)
169+
e1 = streamer.append('hello ')
170+
e2 = streamer.append('world')
171+
self.assertEqual(e1.artifact.artifact_id, e2.artifact.artifact_id)
172+
173+
def test_uses_explicit_artifact_id(self):
174+
streamer = ArtifactStreamer(
175+
self.context_id, self.task_id, artifact_id='my-fixed-id'
176+
)
177+
event = streamer.append('chunk')
178+
self.assertEqual(event.artifact.artifact_id, 'my-fixed-id')
179+
180+
@patch('a2a.utils.artifact.uuid.uuid4')
181+
def test_generated_id_comes_from_uuid4(self, mock_uuid4):
182+
mock_uuid = uuid.UUID('abcdef12-1234-5678-1234-567812345678')
183+
mock_uuid4.return_value = mock_uuid
184+
streamer = ArtifactStreamer(self.context_id, self.task_id)
185+
self.assertEqual(streamer._artifact_id, str(mock_uuid))
186+
187+
def test_default_name_is_response(self):
188+
streamer = ArtifactStreamer(self.context_id, self.task_id)
189+
event = streamer.append('text')
190+
self.assertEqual(event.artifact.name, 'response')
191+
192+
def test_custom_name(self):
193+
streamer = ArtifactStreamer(
194+
self.context_id, self.task_id, name='summary'
195+
)
196+
event = streamer.append('text')
197+
self.assertEqual(event.artifact.name, 'summary')
198+
199+
def test_append_returns_task_artifact_update_event(self):
200+
streamer = ArtifactStreamer(self.context_id, self.task_id)
201+
event = streamer.append('chunk')
202+
self.assertIsInstance(event, TaskArtifactUpdateEvent)
203+
204+
def test_append_sets_correct_context_and_task(self):
205+
streamer = ArtifactStreamer(self.context_id, self.task_id)
206+
event = streamer.append('chunk')
207+
self.assertEqual(event.context_id, self.context_id)
208+
self.assertEqual(event.task_id, self.task_id)
209+
210+
def test_append_sets_append_true_last_chunk_false(self):
211+
streamer = ArtifactStreamer(self.context_id, self.task_id)
212+
event = streamer.append('chunk')
213+
self.assertTrue(event.append)
214+
self.assertFalse(event.last_chunk)
215+
216+
def test_append_creates_single_text_part(self):
217+
streamer = ArtifactStreamer(self.context_id, self.task_id)
218+
event = streamer.append('hello')
219+
self.assertEqual(len(event.artifact.parts), 1)
220+
self.assertTrue(event.artifact.parts[0].HasField('text'))
221+
self.assertEqual(event.artifact.parts[0].text, 'hello')
222+
223+
def test_finalize_returns_task_artifact_update_event(self):
224+
streamer = ArtifactStreamer(self.context_id, self.task_id)
225+
event = streamer.finalize()
226+
self.assertIsInstance(event, TaskArtifactUpdateEvent)
227+
228+
def test_finalize_sets_append_true_last_chunk_true(self):
229+
streamer = ArtifactStreamer(self.context_id, self.task_id)
230+
event = streamer.finalize()
231+
self.assertTrue(event.append)
232+
self.assertTrue(event.last_chunk)
233+
234+
def test_finalize_has_empty_parts(self):
235+
streamer = ArtifactStreamer(self.context_id, self.task_id)
236+
event = streamer.finalize()
237+
self.assertEqual(len(event.artifact.parts), 0)
238+
239+
def test_finalize_uses_same_artifact_id_as_append(self):
240+
streamer = ArtifactStreamer(self.context_id, self.task_id)
241+
append_event = streamer.append('text')
242+
finalize_event = streamer.finalize()
243+
self.assertEqual(
244+
append_event.artifact.artifact_id,
245+
finalize_event.artifact.artifact_id,
246+
)
247+
248+
def test_multiple_appends_all_share_artifact_id(self):
249+
streamer = ArtifactStreamer(self.context_id, self.task_id)
250+
events = [streamer.append(f'chunk-{i}') for i in range(5)]
251+
ids = {e.artifact.artifact_id for e in events}
252+
self.assertEqual(len(ids), 1)
253+
254+
def test_multiple_appends_carry_distinct_text(self):
255+
streamer = ArtifactStreamer(self.context_id, self.task_id)
256+
texts = ['Hello, ', 'world', '!']
257+
events = [streamer.append(t) for t in texts]
258+
result_texts = [e.artifact.parts[0].text for e in events]
259+
self.assertEqual(result_texts, texts)
260+
261+
160262
if __name__ == '__main__':
161263
unittest.main()

0 commit comments

Comments
 (0)