ya-agent-stream-protocol 0.86.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ docs/source
2
+
3
+ # From https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore
4
+
5
+ # Byte-compiled / optimized / DLL files
6
+ __pycache__/
7
+ *.py[cod]
8
+ *$py.class
9
+
10
+ # C extensions
11
+ *.so
12
+
13
+ # Distribution / packaging
14
+ .Python
15
+ build/
16
+ develop-eggs/
17
+ dist/
18
+ downloads/
19
+ eggs/
20
+ .eggs/
21
+ lib/
22
+ !apps/
23
+ !apps/ya-claw-web/
24
+ !apps/ya-claw-web/src/
25
+ !apps/ya-claw-web/src/lib/
26
+ !apps/ya-claw-web/src/lib/**
27
+ lib64/
28
+ parts/
29
+ sdist/
30
+ var/
31
+ wheels/
32
+ share/python-wheels/
33
+ *.egg-info/
34
+ .installed.cfg
35
+ *.egg
36
+ MANIFEST
37
+
38
+ # PyInstaller
39
+ # Usually these files are written by a python script from a template
40
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
41
+ *.manifest
42
+ *.spec
43
+
44
+ # Installer logs
45
+ pip-log.txt
46
+ pip-delete-this-directory.txt
47
+
48
+ # Unit test / coverage reports
49
+ htmlcov/
50
+ .tox/
51
+ .nox/
52
+ .coverage
53
+ .coverage.*
54
+ .cache
55
+ nosetests.xml
56
+ coverage.xml
57
+ *.cover
58
+ *.py,cover
59
+ .hypothesis/
60
+ .pytest_cache/
61
+ .bench/
62
+ cover/
63
+
64
+ # Translations
65
+ *.mo
66
+ *.pot
67
+
68
+ # Django stuff:
69
+ *.log
70
+ local_settings.py
71
+ db.sqlite3
72
+ db.sqlite3-journal
73
+
74
+ # Flask stuff:
75
+ instance/
76
+ .webassets-cache
77
+
78
+ # Scrapy stuff:
79
+ .scrapy
80
+
81
+ # Sphinx documentation
82
+ docs/_build/
83
+
84
+ # PyBuilder
85
+ .pybuilder/
86
+ target/
87
+
88
+ # Jupyter Notebook
89
+ .ipynb_checkpoints
90
+
91
+ # IPython
92
+ profile_default/
93
+ ipython_config.py
94
+
95
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
96
+ __pypackages__/
97
+
98
+ # Celery stuff
99
+ celerybeat-schedule
100
+ celerybeat.pid
101
+
102
+ # SageMath parsed files
103
+ *.sage.py
104
+
105
+ # Environments
106
+ .env
107
+ .yaai/
108
+ **/.yaai/
109
+ .venv
110
+ env/
111
+ venv/
112
+ ENV/
113
+ env.bak/
114
+ venv.bak/
115
+
116
+ # Spyder project settings
117
+ .spyderproject
118
+ .spyproject
119
+
120
+ # Rope project settings
121
+ .ropeproject
122
+
123
+ # mkdocs documentation
124
+ /site
125
+
126
+ # mypy
127
+ .mypy_cache/
128
+ .pyright/
129
+ .dmypy.json
130
+ dmypy.json
131
+
132
+ # Pyre type checker
133
+ .pyre/
134
+
135
+ # pytype static type analyzer
136
+ .pytype/
137
+
138
+ # Cython debug symbols
139
+ cython_debug/
140
+
141
+ # Vscode config files
142
+ # .vscode/
143
+
144
+ # PyCharm
145
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
146
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
147
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
148
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
149
+ #.idea/
150
+
151
+ TO-DO.json
152
+ dev/
153
+ ya_agent_sdk/sandbox/shell/templates/public
154
+
155
+ # Sync automaticlly
156
+ yaacli/yaacli/skills/building-agents
157
+
158
+ # Frontend
159
+ node_modules/
160
+ apps/*/dist/
161
+ !apps/ya-desktop/
162
+ !apps/ya-desktop/src/
163
+ !apps/ya-desktop/src/**
164
+ !apps/ya-desktop/src-tauri/
165
+ !apps/ya-desktop/src-tauri/resources/
166
+ !apps/ya-desktop/src-tauri/resources/uv/
167
+ !apps/ya-desktop/src-tauri/resources/uv/.gitkeep
168
+ apps/ya-desktop/src-tauri/resources/uv/uv
169
+ apps/ya-desktop/src-tauri/resources/uv/uv.exe
170
+ apps/ya-desktop/src-tauri/gen/schemas/linux-schema.json
171
+ *.tsbuildinfo
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: ya-agent-stream-protocol
3
+ Version: 0.86.0
4
+ Summary: Shared stream protocol adapters between ya-agent-sdk and applications
5
+ Project-URL: Repository, https://github.com/wh1isper/ya-mono
6
+ Author-email: wh1isper <jizhongsheng957@gmail.com>
7
+ Keywords: agent,agui,protocol,python,stream
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: <3.14,>=3.11
16
+ Requires-Dist: ag-ui-protocol>=0.1.18
17
+ Requires-Dist: pydantic-ai<2,>=1.100.0
18
+ Requires-Dist: pydantic>=2.0.0
19
+ Requires-Dist: ya-agent-sdk==0.86.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # ya-agent-stream-protocol
23
+
24
+ Shared stream protocol adapters between `ya-agent-sdk` and applications.
25
+
26
+ The package provides AGUI event adaptation, compact replay buffers, message artifact validation, and SSE framing helpers used by YAACLI, YA Claw, and future applications.
@@ -0,0 +1,5 @@
1
+ # ya-agent-stream-protocol
2
+
3
+ Shared stream protocol adapters between `ya-agent-sdk` and applications.
4
+
5
+ The package provides AGUI event adaptation, compact replay buffers, message artifact validation, and SSE framing helpers used by YAACLI, YA Claw, and future applications.
@@ -0,0 +1,58 @@
1
+ [project]
2
+ name = "ya-agent-stream-protocol"
3
+ dynamic = ["version", "dependencies"]
4
+ description = "Shared stream protocol adapters between ya-agent-sdk and applications"
5
+ authors = [{ name = "wh1isper", email = "jizhongsheng957@gmail.com" }]
6
+ readme = "README.md"
7
+ keywords = ["python", "agent", "stream", "protocol", "agui"]
8
+ requires-python = ">=3.11,<3.14"
9
+ classifiers = [
10
+ "Intended Audience :: Developers",
11
+ "Programming Language :: Python",
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Topic :: Software Development :: Libraries :: Python Modules",
17
+ ]
18
+ [project.urls]
19
+ Repository = "https://github.com/wh1isper/ya-mono"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "deptry>=0.22.0",
24
+ "pyright>=1.1.0",
25
+ "pytest>=7.2.0",
26
+ "ruff>=0.9.2",
27
+ ]
28
+
29
+ [build-system]
30
+ requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.version]
34
+ source = "uv-dynamic-versioning"
35
+
36
+ [tool.uv-dynamic-versioning]
37
+ vcs = "git"
38
+ style = "pep440"
39
+ bump = true
40
+
41
+ [tool.hatch.metadata.hooks.uv-dynamic-versioning]
42
+ dependencies = [
43
+ "ag-ui-protocol>=0.1.18",
44
+ "pydantic>=2.0.0",
45
+ "pydantic-ai>=1.100.0,<2",
46
+ "ya-agent-sdk=={{ version }}",
47
+ ]
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["ya_agent_stream_protocol"]
51
+
52
+ [tool.uv.sources]
53
+ ya-agent-sdk = { workspace = true }
54
+
55
+ [tool.deptry]
56
+ ignore = ["DEP001", "DEP002"]
57
+ package_module_name_map = { "ag-ui-protocol" = "ag_ui", "pydantic" = "pydantic", "pydantic-ai" = "pydantic_ai", "ya-agent-sdk" = "ya_agent_sdk" }
58
+ per_rule_ignores = { DEP003 = ["ag_ui", "pydantic", "pydantic_ai", "ya_agent_sdk"], DEP004 = ["pytest"] }
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic_ai import PartDeltaEvent, PartEndEvent, PartStartEvent, TextPartDelta, ThinkingPartDelta
4
+ from pydantic_ai.messages import TextPart, ThinkingPart
5
+ from ya_agent_sdk.context.agent import StreamEvent
6
+ from ya_agent_sdk.events import ModelRequestStartEvent
7
+ from ya_agent_stream_protocol.agui import AguiReplayBuffer, AguiReplayConfig
8
+ from ya_agent_stream_protocol.sdk import AguiAdapterConfig, AguiEventAdapter
9
+
10
+ YAACLI_ADAPTER_CONFIG = AguiAdapterConfig(run_event_prefix="yaacli", stream_metadata_prefix="yaacli")
11
+ CLAW_ADAPTER_CONFIG = AguiAdapterConfig(run_event_prefix="ya_claw")
12
+ YAACLI_REPLAY_CONFIG = AguiReplayConfig(
13
+ agent_id_field="yaacliAgentId",
14
+ main_agent_id="main",
15
+ drop_subagent_detail_events=True,
16
+ )
17
+
18
+
19
+ def test_agui_event_adapter_maps_text_stream_events_and_compacts_replay() -> None:
20
+ adapter = AguiEventAdapter(session_id="session-1", run_id="run-1", config=YAACLI_ADAPTER_CONFIG)
21
+ replay = AguiReplayBuffer(config=YAACLI_REPLAY_CONFIG)
22
+
23
+ stream_events = [
24
+ StreamEvent(
25
+ agent_id="main",
26
+ agent_name="main",
27
+ event=ModelRequestStartEvent(event_id="run-1", loop_index=0, message_count=0),
28
+ ),
29
+ StreamEvent(agent_id="main", agent_name="main", event=PartStartEvent(index=0, part=TextPart(content=""))),
30
+ StreamEvent(
31
+ agent_id="main",
32
+ agent_name="main",
33
+ event=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")),
34
+ ),
35
+ StreamEvent(
36
+ agent_id="main",
37
+ agent_name="main",
38
+ event=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="world")),
39
+ ),
40
+ StreamEvent(
41
+ agent_id="main", agent_name="main", event=PartEndEvent(index=0, part=TextPart(content="hello world"))
42
+ ),
43
+ ]
44
+
45
+ live_events: list[dict[str, object]] = []
46
+ for stream_event in stream_events:
47
+ mapped = adapter.adapt_stream_event(stream_event)
48
+ live_events.extend(mapped)
49
+ for item in mapped:
50
+ replay.append(item)
51
+
52
+ assert live_events[0]["type"] == "CUSTOM"
53
+ assert live_events[0]["name"] == "ya_agent.model_request_start"
54
+ assert live_events[1]["yaacliAgentId"] == "main"
55
+ assert [event["type"] for event in live_events[1:]] == [
56
+ "TEXT_MESSAGE_START",
57
+ "TEXT_MESSAGE_CHUNK",
58
+ "TEXT_MESSAGE_CHUNK",
59
+ "TEXT_MESSAGE_END",
60
+ ]
61
+
62
+ replay.append(adapter.build_run_finished_event(result={"output_text": "hello world"}))
63
+
64
+ compacted = replay.snapshot()
65
+ assert [event["type"] for event in compacted] == ["CUSTOM", "TEXT_MESSAGE_CHUNK", "RUN_FINISHED"]
66
+ assert compacted[1]["delta"] == "hello world"
67
+ assert compacted[2]["result"] == {"output_text": "hello world"}
68
+
69
+
70
+ def test_agui_event_adapter_run_started_excludes_input_parts() -> None:
71
+ adapter = AguiEventAdapter(session_id="session-1", run_id="run-1", config=CLAW_ADAPTER_CONFIG)
72
+
73
+ event = adapter.build_run_started_event(input_parts=[{"type": "text", "text": "hello"}])
74
+
75
+ assert event["type"] == "RUN_STARTED"
76
+ assert "input" not in event
77
+
78
+
79
+ def test_agui_replay_buffer_keeps_runs_separate() -> None:
80
+ replay = AguiReplayBuffer()
81
+ replay.append({"type": "RUN_STARTED", "runId": "run-1"})
82
+ replay.append({"type": "TEXT_MESSAGE_CHUNK", "messageId": "m1", "delta": "first"})
83
+ replay.append({"type": "RUN_FINISHED", "runId": "run-1"})
84
+ replay.append({"type": "RUN_STARTED", "runId": "run-2"})
85
+ replay.append({"type": "TEXT_MESSAGE_CHUNK", "messageId": "m1", "delta": "second"})
86
+
87
+ compacted = replay.snapshot()
88
+ text_chunks = [event for event in compacted if event["type"] == "TEXT_MESSAGE_CHUNK"]
89
+ assert [event["delta"] for event in text_chunks] == ["first", "second"]
90
+
91
+
92
+ def test_agui_replay_buffer_merges_tool_call_chunks() -> None:
93
+ replay = AguiReplayBuffer()
94
+ replay.append({
95
+ "type": "TOOL_CALL_CHUNK",
96
+ "toolCallId": "tool-1",
97
+ "toolCallName": "delegate",
98
+ "delta": '{"prompt":',
99
+ })
100
+ replay.append({"type": "TOOL_CALL_CHUNK", "toolCallId": "tool-1", "delta": '"hello"}'})
101
+ replay.append({
102
+ "type": "TOOL_CALL_RESULT",
103
+ "toolCallId": "tool-1",
104
+ "messageId": "tool-1:result",
105
+ "content": "done",
106
+ "role": "tool",
107
+ })
108
+
109
+ compacted = replay.snapshot()
110
+ assert compacted[0]["type"] == "TOOL_CALL_CHUNK"
111
+ assert compacted[0]["toolCallName"] == "delegate"
112
+ assert compacted[0]["delta"] == '{"prompt":"hello"}'
113
+ assert compacted[1]["type"] == "TOOL_CALL_RESULT"
114
+
115
+
116
+ def test_agui_replay_buffer_drops_subagent_detail_events_when_configured() -> None:
117
+ adapter = AguiEventAdapter(session_id="session-1", run_id="run-1", config=YAACLI_ADAPTER_CONFIG)
118
+ replay = AguiReplayBuffer(config=YAACLI_REPLAY_CONFIG)
119
+
120
+ stream_events = [
121
+ StreamEvent(
122
+ agent_id="worker-1",
123
+ agent_name="worker",
124
+ event=PartStartEvent(index=0, part=ThinkingPart(content="")),
125
+ ),
126
+ StreamEvent(
127
+ agent_id="worker-1",
128
+ agent_name="worker",
129
+ event=PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="hidden thought")),
130
+ ),
131
+ StreamEvent(
132
+ agent_id="worker-1",
133
+ agent_name="worker",
134
+ event=PartEndEvent(index=0, part=ThinkingPart(content="hidden thought")),
135
+ ),
136
+ StreamEvent(agent_id="worker-1", agent_name="worker", event=PartStartEvent(index=1, part=TextPart(content=""))),
137
+ StreamEvent(
138
+ agent_id="worker-1",
139
+ agent_name="worker",
140
+ event=PartDeltaEvent(index=1, delta=TextPartDelta(content_delta="hidden text")),
141
+ ),
142
+ StreamEvent(
143
+ agent_id="worker-1", agent_name="worker", event=PartEndEvent(index=1, part=TextPart(content="hidden text"))
144
+ ),
145
+ ]
146
+
147
+ live_events: list[dict[str, object]] = []
148
+ for stream_event in stream_events:
149
+ mapped = adapter.adapt_stream_event(stream_event)
150
+ live_events.extend(mapped)
151
+ for item in mapped:
152
+ replay.append(item)
153
+
154
+ assert any(event["type"] == "TEXT_MESSAGE_CHUNK" for event in live_events)
155
+ assert any(event["type"] == "REASONING_MESSAGE_CHUNK" for event in live_events)
156
+ assert replay.snapshot() == []
157
+
158
+
159
+ def test_agui_adapter_maps_run_custom_event_namespace() -> None:
160
+ adapter = AguiEventAdapter(session_id="session-1", run_id="run-1", config=CLAW_ADAPTER_CONFIG)
161
+
162
+ queued = adapter.build_run_custom_event("run_queued", {"status": "queued"})
163
+ finished = adapter.build_run_finished_event(result={"output_text": "done"})
164
+ errored = adapter.build_run_error_event(message="boom", code="error")
165
+
166
+ assert queued["type"] == "CUSTOM"
167
+ assert queued["name"] == "ya_claw.run_queued"
168
+ assert finished["type"] == "RUN_FINISHED"
169
+ assert finished["result"] == {"output_text": "done"}
170
+ assert errored["type"] == "RUN_ERROR"
171
+ assert errored["message"] == "boom"
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import pytest
6
+ from ya_agent_stream_protocol.agui import (
7
+ BufferedStreamEvent,
8
+ format_sse_event,
9
+ parse_message_events,
10
+ resolve_event_cursor,
11
+ )
12
+
13
+
14
+ def test_parse_message_events_accepts_top_level_event_array() -> None:
15
+ payload = [{"type": "TEXT_MESSAGE_CHUNK", "messageId": "m1", "delta": "hello"}]
16
+
17
+ assert parse_message_events(payload) == payload
18
+
19
+
20
+ @pytest.mark.parametrize("payload", [{"events": []}, "[]", 123])
21
+ def test_parse_message_events_rejects_non_array_payload(payload: object) -> None:
22
+ with pytest.raises(TypeError, match="top-level JSON array"):
23
+ parse_message_events(payload) # type: ignore[arg-type]
24
+
25
+
26
+ def test_parse_message_events_rejects_non_object_entries() -> None:
27
+ with pytest.raises(TypeError, match="AGUI event objects"):
28
+ parse_message_events([{"type": "message"}, "bad-entry"])
29
+
30
+
31
+ def test_sse_formatting_uses_agui_type_as_event_name() -> None:
32
+ event = BufferedStreamEvent(id="2", payload={"type": "RUN_FINISHED", "result": {"output_text": "done"}})
33
+
34
+ framed = format_sse_event(event)
35
+
36
+ assert framed["id"] == "2"
37
+ assert framed["event"] == "RUN_FINISHED"
38
+ assert json.loads(framed["data"])["result"] == {"output_text": "done"}
39
+
40
+
41
+ @pytest.mark.parametrize(("last_event_id", "cursor"), [(None, 0), ("0", 0), ("1", 1), ("bad", 0), ("-1", 0)])
42
+ def test_resolve_event_cursor(last_event_id: str | None, cursor: int) -> None:
43
+ assert resolve_event_cursor(last_event_id) == cursor
@@ -0,0 +1,3 @@
1
+ from ya_agent_stream_protocol.json_types import JsonArray, JsonObject, JsonValue
2
+
3
+ __all__ = ["JsonArray", "JsonObject", "JsonValue"]
@@ -0,0 +1,37 @@
1
+ from ya_agent_stream_protocol.agui.events import dump_agui_event
2
+ from ya_agent_stream_protocol.agui.replay import (
3
+ AguiReplayBuffer,
4
+ AguiReplayConfig,
5
+ compact_agui_events,
6
+ is_subagent_detail_event,
7
+ is_subagent_event,
8
+ )
9
+ from ya_agent_stream_protocol.agui.sse import (
10
+ BufferedStreamEvent,
11
+ build_buffered_stream_event,
12
+ format_sse_event,
13
+ resolve_event_cursor,
14
+ )
15
+ from ya_agent_stream_protocol.agui.validation import (
16
+ parse_message_events,
17
+ parse_required_message_events,
18
+ validate_agui_events,
19
+ validate_display_events,
20
+ )
21
+
22
+ __all__ = [
23
+ "AguiReplayBuffer",
24
+ "AguiReplayConfig",
25
+ "BufferedStreamEvent",
26
+ "build_buffered_stream_event",
27
+ "compact_agui_events",
28
+ "dump_agui_event",
29
+ "format_sse_event",
30
+ "is_subagent_detail_event",
31
+ "is_subagent_event",
32
+ "parse_message_events",
33
+ "parse_required_message_events",
34
+ "resolve_event_cursor",
35
+ "validate_agui_events",
36
+ "validate_display_events",
37
+ ]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from ya_agent_stream_protocol.json_types import JsonObject
8
+
9
+
10
+ def dump_agui_event(event: BaseModel) -> JsonObject:
11
+ payload = event.model_dump(mode="json", exclude_none=True, by_alias=True)
12
+ payload.setdefault("timestamp", int(datetime.now(UTC).timestamp() * 1000))
13
+ return payload # type: ignore[return-value]
@@ -0,0 +1,236 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from ya_agent_stream_protocol.json_types import JsonObject, JsonValue
7
+
8
+ _REPLAY_DROP_EVENT_TYPES = frozenset({
9
+ "TEXT_MESSAGE_START",
10
+ "TEXT_MESSAGE_END",
11
+ "REASONING_MESSAGE_START",
12
+ "REASONING_MESSAGE_END",
13
+ "TOOL_CALL_START",
14
+ "TOOL_CALL_END",
15
+ })
16
+ _SUBAGENT_DETAIL_EVENT_TYPES = frozenset({
17
+ "TEXT_MESSAGE_START",
18
+ "TEXT_MESSAGE_CHUNK",
19
+ "TEXT_MESSAGE_END",
20
+ "REASONING_MESSAGE_START",
21
+ "REASONING_MESSAGE_CHUNK",
22
+ "REASONING_MESSAGE_END",
23
+ "TOOL_CALL_START",
24
+ "TOOL_CALL_CHUNK",
25
+ "TOOL_CALL_END",
26
+ "TOOL_CALL_RESULT",
27
+ })
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class AguiReplayConfig:
32
+ agent_id_field: str | None = None
33
+ main_agent_id: str = "main"
34
+ drop_subagent_detail_events: bool = False
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class AguiReplayBuffer:
39
+ config: AguiReplayConfig = field(default_factory=AguiReplayConfig)
40
+ events: list[JsonObject] = field(default_factory=list)
41
+ _text_chunk_index: dict[str, int] = field(default_factory=dict)
42
+ _reasoning_chunk_index: dict[str, int] = field(default_factory=dict)
43
+ _tool_chunk_index: dict[str, int] = field(default_factory=dict)
44
+ _chunk_fragments: dict[int, list[str]] = field(default_factory=dict)
45
+
46
+ def append(self, event: dict[str, Any]) -> None:
47
+ event_type = str(event.get("type", "")).strip()
48
+ if event_type == "":
49
+ return
50
+ if self.is_subagent_detail_event(event):
51
+ return
52
+ if event_type in _REPLAY_DROP_EVENT_TYPES:
53
+ return
54
+ if event_type == "TEXT_MESSAGE_CHUNK":
55
+ self._merge_text_chunk(event)
56
+ return
57
+ if event_type == "REASONING_MESSAGE_CHUNK":
58
+ self._merge_reasoning_chunk(event)
59
+ return
60
+ if event_type == "TOOL_CALL_CHUNK":
61
+ self._merge_tool_call_chunk(event)
62
+ return
63
+ self._append_passthrough_event(event)
64
+
65
+ def extend_snapshot(self, events: list[dict[str, Any]]) -> None:
66
+ self.clear()
67
+ for event in events:
68
+ self.append(event)
69
+
70
+ def snapshot(self) -> list[JsonObject]:
71
+ snapshot: list[JsonObject] = []
72
+ for index, event in enumerate(self.events):
73
+ event_copy = dict(event)
74
+ fragments = self._chunk_fragments.get(index)
75
+ if fragments:
76
+ event_copy["delta"] = "".join(fragments)
77
+ snapshot.append(event_copy)
78
+ return snapshot
79
+
80
+ def clear(self) -> None:
81
+ self.events.clear()
82
+ self._text_chunk_index.clear()
83
+ self._reasoning_chunk_index.clear()
84
+ self._tool_chunk_index.clear()
85
+ self._chunk_fragments.clear()
86
+
87
+ def is_subagent_event(self, event: dict[str, Any]) -> bool:
88
+ agent_id_field = self.config.agent_id_field
89
+ if agent_id_field is None:
90
+ return False
91
+ agent_id = _normalized_identifier(_event_field(event, agent_id_field, _camel_to_snake(agent_id_field)))
92
+ return agent_id is not None and agent_id != self.config.main_agent_id
93
+
94
+ def is_subagent_detail_event(self, event: dict[str, Any]) -> bool:
95
+ if not self.config.drop_subagent_detail_events:
96
+ return False
97
+ event_type = str(event.get("type", "")).strip()
98
+ return event_type in _SUBAGENT_DETAIL_EVENT_TYPES and self.is_subagent_event(event)
99
+
100
+ def _merge_text_chunk(self, event: dict[str, Any]) -> None:
101
+ message_id = _normalized_identifier(_event_field(event, "messageId", "message_id"))
102
+ if message_id is None:
103
+ self._append_passthrough_event(event)
104
+ return
105
+ existing_index = self._text_chunk_index.get(message_id)
106
+ if existing_index is None:
107
+ self._text_chunk_index[message_id] = self._append_chunk_event(event)
108
+ return
109
+ self._append_delta_fragment(existing_index, event.get("delta"))
110
+ existing = self.events[existing_index]
111
+ if existing.get("role") is None and event.get("role") is not None:
112
+ existing["role"] = event.get("role")
113
+ if existing.get("name") is None and event.get("name") is not None:
114
+ existing["name"] = event.get("name")
115
+
116
+ def _merge_reasoning_chunk(self, event: dict[str, Any]) -> None:
117
+ message_id = _normalized_identifier(_event_field(event, "messageId", "message_id"))
118
+ if message_id is None:
119
+ self._append_passthrough_event(event)
120
+ return
121
+ existing_index = self._reasoning_chunk_index.get(message_id)
122
+ if existing_index is None:
123
+ self._reasoning_chunk_index[message_id] = self._append_chunk_event(event)
124
+ return
125
+ self._append_delta_fragment(existing_index, event.get("delta"))
126
+
127
+ def _merge_tool_call_chunk(self, event: dict[str, Any]) -> None:
128
+ tool_call_id = _normalized_identifier(_event_field(event, "toolCallId", "tool_call_id"))
129
+ if tool_call_id is None:
130
+ self._append_passthrough_event(event)
131
+ return
132
+ existing_index = self._tool_chunk_index.get(tool_call_id)
133
+ if existing_index is None:
134
+ self._tool_chunk_index[tool_call_id] = self._append_chunk_event(event)
135
+ return
136
+ self._append_delta_fragment(existing_index, event.get("delta"))
137
+ existing = self.events[existing_index]
138
+ tool_call_name = _event_field(event, "toolCallName", "tool_call_name")
139
+ if existing.get("toolCallName") is None and tool_call_name is not None:
140
+ existing["toolCallName"] = tool_call_name
141
+ parent_message_id = _event_field(event, "parentMessageId", "parent_message_id")
142
+ if existing.get("parentMessageId") is None and parent_message_id is not None:
143
+ existing["parentMessageId"] = parent_message_id
144
+
145
+ def _append_passthrough_event(self, event: dict[str, Any]) -> None:
146
+ self._forget_previous_run_state_if_needed(event)
147
+ self.events.append(_coerce_json_object(event))
148
+
149
+ def _append_chunk_event(self, event: dict[str, Any]) -> int:
150
+ self._forget_previous_run_state_if_needed(event)
151
+ event_copy = _coerce_json_object(event)
152
+ fragment = _delta_fragment(event_copy.get("delta"))
153
+ if fragment is not None:
154
+ event_copy["delta"] = ""
155
+ index = len(self.events)
156
+ self.events.append(event_copy)
157
+ if fragment is not None:
158
+ self._chunk_fragments[index] = [fragment]
159
+ return index
160
+
161
+ def _append_delta_fragment(self, index: int, value: object) -> None:
162
+ fragment = _delta_fragment(value)
163
+ if fragment is None:
164
+ return
165
+ self._chunk_fragments.setdefault(index, []).append(fragment)
166
+
167
+ def _forget_previous_run_state_if_needed(self, event: dict[str, Any]) -> None:
168
+ if event.get("type") != "RUN_STARTED":
169
+ return
170
+ self._text_chunk_index.clear()
171
+ self._reasoning_chunk_index.clear()
172
+ self._tool_chunk_index.clear()
173
+ self._chunk_fragments = {
174
+ index: fragments for index, fragments in self._chunk_fragments.items() if index < len(self.events)
175
+ }
176
+
177
+
178
+ def compact_agui_events(events: list[dict[str, Any]], *, config: AguiReplayConfig | None = None) -> list[JsonObject]:
179
+ replay = AguiReplayBuffer(config=config or AguiReplayConfig())
180
+ for event in events:
181
+ replay.append(event)
182
+ return replay.snapshot()
183
+
184
+
185
+ def is_subagent_detail_event(
186
+ event: dict[str, Any], *, agent_id_field: str = "yaacliAgentId", main_agent_id: str = "main"
187
+ ) -> bool:
188
+ replay = AguiReplayBuffer(
189
+ config=AguiReplayConfig(
190
+ agent_id_field=agent_id_field,
191
+ main_agent_id=main_agent_id,
192
+ drop_subagent_detail_events=True,
193
+ )
194
+ )
195
+ return replay.is_subagent_detail_event(event)
196
+
197
+
198
+ def is_subagent_event(
199
+ event: dict[str, Any], *, agent_id_field: str = "yaacliAgentId", main_agent_id: str = "main"
200
+ ) -> bool:
201
+ replay = AguiReplayBuffer(config=AguiReplayConfig(agent_id_field=agent_id_field, main_agent_id=main_agent_id))
202
+ return replay.is_subagent_event(event)
203
+
204
+
205
+ def _coerce_json_object(event: dict[str, Any]) -> JsonObject:
206
+ return dict(event) # type: ignore[return-value]
207
+
208
+
209
+ def _event_field(event: dict[str, Any], camel_name: str, snake_name: str) -> JsonValue:
210
+ if camel_name in event:
211
+ return event[camel_name] # type: ignore[return-value]
212
+ return event.get(snake_name) # type: ignore[return-value]
213
+
214
+
215
+ def _normalized_identifier(value: object) -> str | None:
216
+ if not isinstance(value, str):
217
+ return None
218
+ normalized = value.strip()
219
+ return normalized or None
220
+
221
+
222
+ def _delta_fragment(value: object) -> str | None:
223
+ if value is None:
224
+ return None
225
+ if isinstance(value, str):
226
+ return value
227
+ return str(value)
228
+
229
+
230
+ def _camel_to_snake(value: str) -> str:
231
+ output: list[str] = []
232
+ for index, character in enumerate(value):
233
+ if character.isupper() and index > 0:
234
+ output.append("_")
235
+ output.append(character.lower())
236
+ return "".join(output)
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from ya_agent_stream_protocol.json_types import JsonObject
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class BufferedStreamEvent:
12
+ id: str
13
+ payload: JsonObject
14
+ terminal: bool = False
15
+
16
+
17
+ def format_sse_event(event: BufferedStreamEvent) -> dict[str, str]:
18
+ return {
19
+ "id": event.id,
20
+ "event": str(event.payload.get("type", "message")),
21
+ "data": json.dumps(event.payload, ensure_ascii=False),
22
+ }
23
+
24
+
25
+ def resolve_event_cursor(last_event_id: str | None) -> int:
26
+ if last_event_id is None:
27
+ return 0
28
+ try:
29
+ return max(int(last_event_id), 0)
30
+ except ValueError:
31
+ return 0
32
+
33
+
34
+ def build_buffered_stream_event(
35
+ event_id: int | str, payload: dict[str, Any], *, terminal: bool = False
36
+ ) -> BufferedStreamEvent:
37
+ return BufferedStreamEvent(id=str(event_id), payload=dict(payload), terminal=terminal) # type: ignore[arg-type]
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ya_agent_stream_protocol.json_types import JsonObject, JsonValue
6
+
7
+
8
+ def validate_agui_events(
9
+ raw_payload: object,
10
+ *,
11
+ payload_name: str = "AGUI events payload",
12
+ allow_none: bool = False,
13
+ ) -> list[JsonObject] | None:
14
+ if raw_payload is None and allow_none:
15
+ return None
16
+ if not isinstance(raw_payload, list):
17
+ raise TypeError(f"{payload_name} must be a top-level JSON array of AGUI event objects")
18
+ parsed_events: list[JsonObject] = [event for event in raw_payload if isinstance(event, dict)]
19
+ if len(parsed_events) != len(raw_payload):
20
+ raise TypeError(f"{payload_name} must contain only AGUI event objects")
21
+ return parsed_events
22
+
23
+
24
+ def parse_message_events(raw_message_payload: JsonValue) -> list[JsonObject] | None:
25
+ return validate_agui_events(raw_message_payload, payload_name="message payload", allow_none=True)
26
+
27
+
28
+ def parse_required_message_events(
29
+ raw_message_payload: JsonValue, *, payload_name: str = "message payload"
30
+ ) -> list[JsonObject]:
31
+ parsed = validate_agui_events(raw_message_payload, payload_name=payload_name, allow_none=False)
32
+ if parsed is None:
33
+ raise TypeError(f"{payload_name} must be a top-level JSON array of AGUI event objects")
34
+ return parsed
35
+
36
+
37
+ def validate_display_events(raw_payload: object) -> list[JsonObject]:
38
+ parsed = validate_agui_events(raw_payload, payload_name="display_messages payload", allow_none=False)
39
+ if parsed is None:
40
+ raise TypeError("display_messages payload must be a top-level JSON array of event objects")
41
+ return parsed
42
+
43
+
44
+ def coerce_json_object(value: dict[str, Any]) -> JsonObject:
45
+ return dict(value) # type: ignore[return-value]
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
4
+ JsonArray = list[JsonValue]
5
+ JsonObject = dict[str, JsonValue]
@@ -0,0 +1,3 @@
1
+ from ya_agent_stream_protocol.sdk.agui_adapter import AguiAdapterConfig, AguiEventAdapter
2
+
3
+ __all__ = ["AguiAdapterConfig", "AguiEventAdapter"]
@@ -0,0 +1,450 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import asdict, dataclass, field, is_dataclass
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import cast
9
+
10
+ from ag_ui.core.events import (
11
+ CustomEvent,
12
+ ReasoningMessageChunkEvent,
13
+ ReasoningMessageEndEvent,
14
+ ReasoningMessageStartEvent,
15
+ RunErrorEvent,
16
+ RunFinishedEvent,
17
+ RunStartedEvent,
18
+ TextMessageChunkEvent,
19
+ TextMessageEndEvent,
20
+ TextMessageStartEvent,
21
+ ToolCallChunkEvent,
22
+ ToolCallEndEvent,
23
+ ToolCallResultEvent,
24
+ ToolCallStartEvent,
25
+ )
26
+ from pydantic import BaseModel
27
+ from pydantic_ai import (
28
+ FinalResultEvent,
29
+ FunctionToolResultEvent,
30
+ OutputToolResultEvent,
31
+ PartDeltaEvent,
32
+ PartEndEvent,
33
+ PartStartEvent,
34
+ TextPartDelta,
35
+ ThinkingPartDelta,
36
+ ToolCallPartDelta,
37
+ )
38
+ from pydantic_ai.messages import RetryPromptPart, TextPart, ThinkingPart, ToolCallPart, ToolReturnPart
39
+ from ya_agent_sdk.context.agent import StreamEvent
40
+ from ya_agent_sdk.events import MessageReceivedEvent, ModelRequestStartEvent, UsageSnapshotEvent
41
+
42
+ from ya_agent_stream_protocol.agui.events import dump_agui_event
43
+ from ya_agent_stream_protocol.json_types import JsonObject, JsonValue
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class AguiAdapterConfig:
48
+ run_event_prefix: str
49
+ agent_event_prefix: str = "ya_agent"
50
+ stream_metadata_prefix: str | None = None
51
+
52
+
53
+ @dataclass(slots=True)
54
+ class PartCursor:
55
+ kind: str
56
+ part_id: str
57
+ role: str | None = None
58
+ tool_call_name: str | None = None
59
+ emitted_chunk: bool = False
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class AgentCursor:
64
+ loop_index: int = 0
65
+ parts: dict[int, PartCursor] = field(default_factory=dict)
66
+
67
+
68
+ class AguiEventAdapter:
69
+ def __init__(self, *, session_id: str, run_id: str, config: AguiAdapterConfig) -> None:
70
+ self._session_id = session_id
71
+ self._run_id = run_id
72
+ self._config = config
73
+ self._agents: dict[str, AgentCursor] = {}
74
+
75
+ def build_run_started_event(self, *, input_parts: list[JsonObject] | None = None) -> JsonObject:
76
+ _ = input_parts
77
+ return dump_agui_event(RunStartedEvent(thread_id=self._session_id, run_id=self._run_id))
78
+
79
+ def build_run_finished_event(self, result: JsonValue = None) -> JsonObject:
80
+ return dump_agui_event(RunFinishedEvent(thread_id=self._session_id, run_id=self._run_id, result=result))
81
+
82
+ def build_run_error_event(self, *, message: str, code: str | None = None) -> JsonObject:
83
+ return dump_agui_event(RunErrorEvent(message=message, code=code))
84
+
85
+ def build_run_custom_event(self, event_name: str, payload: object) -> JsonObject:
86
+ return dump_agui_event(
87
+ CustomEvent(
88
+ name=f"{self._config.run_event_prefix}.{event_name}",
89
+ value=_serialize_value(payload),
90
+ )
91
+ )
92
+
93
+ def adapt_stream_event(self, stream_event: StreamEvent) -> list[JsonObject]:
94
+ cursor = self._agents.setdefault(stream_event.agent_id, AgentCursor())
95
+ event = stream_event.event
96
+
97
+ if isinstance(event, ModelRequestStartEvent):
98
+ cursor.loop_index = event.loop_index
99
+
100
+ if isinstance(event, PartStartEvent):
101
+ return self._with_stream_metadata(stream_event, self._adapt_part_start(stream_event, cursor))
102
+ if isinstance(event, PartDeltaEvent):
103
+ return self._with_stream_metadata(stream_event, self._adapt_part_delta(stream_event, cursor))
104
+ if isinstance(event, PartEndEvent):
105
+ return self._with_stream_metadata(stream_event, self._adapt_part_end(stream_event, cursor))
106
+ if isinstance(event, FunctionToolResultEvent | OutputToolResultEvent):
107
+ return self._with_stream_metadata(stream_event, self._adapt_function_tool_result(stream_event))
108
+ if isinstance(event, FinalResultEvent):
109
+ return [
110
+ self._custom_agent_event(
111
+ event_name="final_result",
112
+ stream_event=stream_event,
113
+ payload={"tool_name": event.tool_name, "tool_call_id": event.tool_call_id},
114
+ )
115
+ ]
116
+ if isinstance(event, UsageSnapshotEvent):
117
+ return [
118
+ self._custom_agent_event(
119
+ event_name="usage_snapshot",
120
+ stream_event=stream_event,
121
+ payload=_serialize_value(event.snapshot) if event.snapshot is not None else None,
122
+ )
123
+ ]
124
+ if isinstance(event, MessageReceivedEvent):
125
+ return [
126
+ self._custom_agent_event(
127
+ event_name="message_received",
128
+ stream_event=stream_event,
129
+ payload={"messages": _serialize_value(event.messages)},
130
+ )
131
+ ]
132
+ return [
133
+ self._custom_agent_event(
134
+ event_name=_camel_to_snake(type(event).__name__),
135
+ stream_event=stream_event,
136
+ payload=_serialize_value(event),
137
+ )
138
+ ]
139
+
140
+ def _adapt_part_start(self, stream_event: StreamEvent, cursor: AgentCursor) -> list[JsonObject]:
141
+ event = cast(PartStartEvent, stream_event.event)
142
+ part = event.part
143
+ if isinstance(part, TextPart):
144
+ message_id = part.id or self._part_id(stream_event.agent_id, cursor.loop_index, event.index, "text")
145
+ cursor.parts[event.index] = PartCursor(kind="text", part_id=message_id, role="assistant")
146
+ events = [
147
+ dump_agui_event(
148
+ TextMessageStartEvent(message_id=message_id, role="assistant", name=stream_event.agent_name)
149
+ )
150
+ ]
151
+ if part.content:
152
+ events.append(
153
+ dump_agui_event(
154
+ TextMessageChunkEvent(
155
+ message_id=message_id,
156
+ role="assistant",
157
+ name=stream_event.agent_name,
158
+ delta=part.content,
159
+ )
160
+ )
161
+ )
162
+ cursor.parts[event.index].emitted_chunk = True
163
+ return events
164
+ if isinstance(part, ThinkingPart):
165
+ message_id = part.id or self._part_id(stream_event.agent_id, cursor.loop_index, event.index, "reasoning")
166
+ cursor.parts[event.index] = PartCursor(kind="reasoning", part_id=message_id, role="reasoning")
167
+ events = [dump_agui_event(ReasoningMessageStartEvent(message_id=message_id, role="reasoning"))]
168
+ if part.content:
169
+ events.append(dump_agui_event(ReasoningMessageChunkEvent(message_id=message_id, delta=part.content)))
170
+ cursor.parts[event.index].emitted_chunk = True
171
+ return events
172
+ if isinstance(part, ToolCallPart):
173
+ tool_call_id = part.tool_call_id
174
+ cursor.parts[event.index] = PartCursor(
175
+ kind="tool_call",
176
+ part_id=tool_call_id,
177
+ tool_call_name=part.tool_name,
178
+ )
179
+ events = [dump_agui_event(ToolCallStartEvent(tool_call_id=tool_call_id, tool_call_name=part.tool_name))]
180
+ chunk_delta = _stringify_tool_call_args(part.args)
181
+ if chunk_delta is not None or part.tool_name:
182
+ events.append(
183
+ dump_agui_event(
184
+ ToolCallChunkEvent(
185
+ tool_call_id=tool_call_id,
186
+ tool_call_name=part.tool_name,
187
+ delta=chunk_delta,
188
+ )
189
+ )
190
+ )
191
+ cursor.parts[event.index].emitted_chunk = True
192
+ return events
193
+ if isinstance(part, ToolReturnPart):
194
+ return [self._tool_result_event(part)]
195
+ if isinstance(part, RetryPromptPart):
196
+ return [
197
+ self._custom_agent_event("retry_prompt_part", stream_event=stream_event, payload=_serialize_value(part))
198
+ ]
199
+ return [self._custom_agent_event("part_start", stream_event=stream_event, payload=_serialize_value(event))]
200
+
201
+ def _adapt_part_delta(self, stream_event: StreamEvent, cursor: AgentCursor) -> list[JsonObject]:
202
+ event = cast(PartDeltaEvent, stream_event.event)
203
+ delta = event.delta
204
+ if isinstance(delta, TextPartDelta):
205
+ part_cursor = self._ensure_text_cursor(stream_event.agent_id, cursor, event.index)
206
+ part_cursor.emitted_chunk = True
207
+ return [
208
+ dump_agui_event(
209
+ TextMessageChunkEvent(
210
+ message_id=part_cursor.part_id,
211
+ role="assistant",
212
+ name=stream_event.agent_name,
213
+ delta=delta.content_delta,
214
+ )
215
+ )
216
+ ]
217
+ if isinstance(delta, ThinkingPartDelta):
218
+ part_cursor = self._ensure_reasoning_cursor(stream_event.agent_id, cursor, event.index)
219
+ events: list[JsonObject] = []
220
+ if delta.content_delta:
221
+ part_cursor.emitted_chunk = True
222
+ events.append(
223
+ dump_agui_event(
224
+ ReasoningMessageChunkEvent(message_id=part_cursor.part_id, delta=delta.content_delta)
225
+ )
226
+ )
227
+ if getattr(delta, "signature_delta", None):
228
+ events.append(
229
+ self._custom_agent_event(
230
+ "reasoning_signature_delta",
231
+ stream_event=stream_event,
232
+ payload={"message_id": part_cursor.part_id, "signature_delta": delta.signature_delta},
233
+ )
234
+ )
235
+ return events
236
+ if isinstance(delta, ToolCallPartDelta):
237
+ part_cursor = self._ensure_tool_call_cursor(stream_event.agent_id, cursor, event.index, delta.tool_call_id)
238
+ if delta.tool_name_delta:
239
+ part_cursor.tool_call_name = f"{part_cursor.tool_call_name or ''}{delta.tool_name_delta}" or None
240
+ part_cursor.emitted_chunk = True
241
+ return [
242
+ dump_agui_event(
243
+ ToolCallChunkEvent(
244
+ tool_call_id=part_cursor.part_id,
245
+ tool_call_name=part_cursor.tool_call_name,
246
+ delta=_stringify_tool_call_args(delta.args_delta),
247
+ )
248
+ )
249
+ ]
250
+ return [self._custom_agent_event("part_delta", stream_event=stream_event, payload=_serialize_value(event))]
251
+
252
+ def _adapt_part_end(self, stream_event: StreamEvent, cursor: AgentCursor) -> list[JsonObject]:
253
+ event = cast(PartEndEvent, stream_event.event)
254
+ part = event.part
255
+ part_cursor = cursor.parts.pop(event.index, None)
256
+ if isinstance(part, TextPart):
257
+ message_id = (
258
+ part_cursor.part_id
259
+ if part_cursor is not None
260
+ else part.id or self._part_id(stream_event.agent_id, cursor.loop_index, event.index, "text")
261
+ )
262
+ events: list[JsonObject] = []
263
+ emitted_chunk = part_cursor.emitted_chunk if part_cursor is not None else False
264
+ if part.content and not emitted_chunk:
265
+ events.append(
266
+ dump_agui_event(
267
+ TextMessageChunkEvent(
268
+ message_id=message_id,
269
+ role="assistant",
270
+ name=stream_event.agent_name,
271
+ delta=part.content,
272
+ )
273
+ )
274
+ )
275
+ events.append(dump_agui_event(TextMessageEndEvent(message_id=message_id)))
276
+ return events
277
+ if isinstance(part, ThinkingPart):
278
+ message_id = (
279
+ part_cursor.part_id
280
+ if part_cursor is not None
281
+ else part.id or self._part_id(stream_event.agent_id, cursor.loop_index, event.index, "reasoning")
282
+ )
283
+ events = []
284
+ emitted_chunk = part_cursor.emitted_chunk if part_cursor is not None else False
285
+ if part.content and not emitted_chunk:
286
+ events.append(dump_agui_event(ReasoningMessageChunkEvent(message_id=message_id, delta=part.content)))
287
+ events.append(dump_agui_event(ReasoningMessageEndEvent(message_id=message_id)))
288
+ return events
289
+ if isinstance(part, ToolCallPart):
290
+ tool_call_id = part.tool_call_id if part_cursor is None else part_cursor.part_id
291
+ events = []
292
+ emitted_chunk = part_cursor.emitted_chunk if part_cursor is not None else False
293
+ if not emitted_chunk:
294
+ events.append(
295
+ dump_agui_event(
296
+ ToolCallChunkEvent(
297
+ tool_call_id=tool_call_id,
298
+ tool_call_name=part.tool_name,
299
+ delta=_stringify_tool_call_args(part.args),
300
+ )
301
+ )
302
+ )
303
+ events.append(dump_agui_event(ToolCallEndEvent(tool_call_id=tool_call_id)))
304
+ return events
305
+ if isinstance(part, ToolReturnPart):
306
+ return [self._tool_result_event(part)]
307
+ if isinstance(part, RetryPromptPart):
308
+ return [
309
+ self._custom_agent_event("retry_prompt_part", stream_event=stream_event, payload=_serialize_value(part))
310
+ ]
311
+ return [self._custom_agent_event("part_end", stream_event=stream_event, payload=_serialize_value(event))]
312
+
313
+ def _adapt_function_tool_result(self, stream_event: StreamEvent) -> list[JsonObject]:
314
+ event = cast(FunctionToolResultEvent | OutputToolResultEvent, stream_event.event)
315
+ part = event.part
316
+ content = event.content if isinstance(event, FunctionToolResultEvent) else None
317
+ if isinstance(part, ToolReturnPart):
318
+ return [self._tool_result_event(part, content=content)]
319
+ if isinstance(part, RetryPromptPart):
320
+ return [
321
+ self._custom_agent_event(
322
+ "retry_prompt_part",
323
+ stream_event=stream_event,
324
+ payload={"part": _serialize_value(part), "content": _serialize_value(content)},
325
+ )
326
+ ]
327
+ return [
328
+ self._custom_agent_event("function_tool_result", stream_event=stream_event, payload=_serialize_value(event))
329
+ ]
330
+
331
+ def _tool_result_event(self, part: ToolReturnPart, *, content: object = None) -> JsonObject:
332
+ tool_call_id = part.tool_call_id
333
+ return dump_agui_event(
334
+ ToolCallResultEvent(
335
+ message_id=f"{tool_call_id}:result",
336
+ tool_call_id=tool_call_id,
337
+ content=_stringify_tool_result(content if content is not None else part.content),
338
+ role="tool",
339
+ )
340
+ )
341
+
342
+ def _ensure_text_cursor(self, agent_id: str, cursor: AgentCursor, index: int) -> PartCursor:
343
+ existing = cursor.parts.get(index)
344
+ if existing is not None:
345
+ return existing
346
+ part_cursor = PartCursor(
347
+ kind="text",
348
+ part_id=self._part_id(agent_id, cursor.loop_index, index, "text"),
349
+ role="assistant",
350
+ )
351
+ cursor.parts[index] = part_cursor
352
+ return part_cursor
353
+
354
+ def _ensure_reasoning_cursor(self, agent_id: str, cursor: AgentCursor, index: int) -> PartCursor:
355
+ existing = cursor.parts.get(index)
356
+ if existing is not None:
357
+ return existing
358
+ part_cursor = PartCursor(
359
+ kind="reasoning",
360
+ part_id=self._part_id(agent_id, cursor.loop_index, index, "reasoning"),
361
+ role="reasoning",
362
+ )
363
+ cursor.parts[index] = part_cursor
364
+ return part_cursor
365
+
366
+ def _ensure_tool_call_cursor(
367
+ self,
368
+ agent_id: str,
369
+ cursor: AgentCursor,
370
+ index: int,
371
+ tool_call_id: str | None,
372
+ ) -> PartCursor:
373
+ existing = cursor.parts.get(index)
374
+ if existing is not None:
375
+ if tool_call_id:
376
+ existing.part_id = tool_call_id
377
+ return existing
378
+ part_cursor = PartCursor(
379
+ kind="tool_call",
380
+ part_id=tool_call_id or self._part_id(agent_id, cursor.loop_index, index, "tool_call"),
381
+ )
382
+ cursor.parts[index] = part_cursor
383
+ return part_cursor
384
+
385
+ def _part_id(self, agent_id: str, loop_index: int, part_index: int, kind: str) -> str:
386
+ return f"{self._run_id}:{agent_id}:{loop_index}:{kind}:{part_index}"
387
+
388
+ def _with_stream_metadata(self, stream_event: StreamEvent, events: list[JsonObject]) -> list[JsonObject]:
389
+ prefix = self._config.stream_metadata_prefix
390
+ if prefix is None:
391
+ return events
392
+ agent_id_key = f"{prefix}AgentId"
393
+ agent_name_key = f"{prefix}AgentName"
394
+ for event in events:
395
+ event[agent_id_key] = stream_event.agent_id
396
+ event[agent_name_key] = stream_event.agent_name
397
+ return events
398
+
399
+ def _custom_agent_event(self, event_name: str, *, stream_event: StreamEvent, payload: object) -> JsonObject:
400
+ return dump_agui_event(
401
+ CustomEvent(
402
+ name=f"{self._config.agent_event_prefix}.{event_name}",
403
+ value={
404
+ "run_id": self._run_id,
405
+ "session_id": self._session_id,
406
+ "agent_id": stream_event.agent_id,
407
+ "agent_name": stream_event.agent_name,
408
+ "payload": _serialize_value(payload),
409
+ },
410
+ )
411
+ )
412
+
413
+
414
+ def _stringify_tool_call_args(value: object) -> str | None:
415
+ if value is None:
416
+ return None
417
+ if isinstance(value, str):
418
+ return value
419
+ return json.dumps(_serialize_value(value), ensure_ascii=False, separators=(",", ":"))
420
+
421
+
422
+ def _stringify_tool_result(value: object) -> str:
423
+ if isinstance(value, str):
424
+ return value
425
+ return json.dumps(_serialize_value(value), ensure_ascii=False)
426
+
427
+
428
+ def _camel_to_snake(value: str) -> str:
429
+ snake = re.sub(r"(?<!^)(?=[A-Z])", "_", value).lower()
430
+ return snake.removesuffix("_event")
431
+
432
+
433
+ def _serialize_value(value: object) -> JsonValue:
434
+ if value is None or isinstance(value, (str, int, float, bool)):
435
+ return value
436
+ if isinstance(value, datetime):
437
+ return value.astimezone(UTC).isoformat()
438
+ if isinstance(value, Path):
439
+ return str(value)
440
+ if isinstance(value, bytes):
441
+ return value.decode("utf-8", errors="replace")
442
+ if isinstance(value, BaseModel):
443
+ return value.model_dump(mode="json") # type: ignore[return-value]
444
+ if is_dataclass(value) and not isinstance(value, type):
445
+ return _serialize_value(asdict(value))
446
+ if isinstance(value, dict):
447
+ return {str(key): _serialize_value(item) for key, item in value.items()}
448
+ if isinstance(value, (list, tuple, set)):
449
+ return [_serialize_value(item) for item in value]
450
+ return str(value)