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.
- ya_agent_stream_protocol-0.86.0/.gitignore +171 -0
- ya_agent_stream_protocol-0.86.0/PKG-INFO +26 -0
- ya_agent_stream_protocol-0.86.0/README.md +5 -0
- ya_agent_stream_protocol-0.86.0/pyproject.toml +58 -0
- ya_agent_stream_protocol-0.86.0/tests/test_agui_adapter.py +171 -0
- ya_agent_stream_protocol-0.86.0/tests/test_agui_validation_sse.py +43 -0
- ya_agent_stream_protocol-0.86.0/ya_agent_stream_protocol/__init__.py +3 -0
- ya_agent_stream_protocol-0.86.0/ya_agent_stream_protocol/agui/__init__.py +37 -0
- ya_agent_stream_protocol-0.86.0/ya_agent_stream_protocol/agui/events.py +13 -0
- ya_agent_stream_protocol-0.86.0/ya_agent_stream_protocol/agui/replay.py +236 -0
- ya_agent_stream_protocol-0.86.0/ya_agent_stream_protocol/agui/sse.py +37 -0
- ya_agent_stream_protocol-0.86.0/ya_agent_stream_protocol/agui/validation.py +45 -0
- ya_agent_stream_protocol-0.86.0/ya_agent_stream_protocol/json_types.py +5 -0
- ya_agent_stream_protocol-0.86.0/ya_agent_stream_protocol/sdk/__init__.py +3 -0
- ya_agent_stream_protocol-0.86.0/ya_agent_stream_protocol/sdk/agui_adapter.py +450 -0
|
@@ -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,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,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)
|