welt-io 0.1.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.
- welt_io-0.1.0/.gitignore +9 -0
- welt_io-0.1.0/CONTRIBUTING.md +48 -0
- welt_io-0.1.0/LICENSE +21 -0
- welt_io-0.1.0/PKG-INFO +72 -0
- welt_io-0.1.0/README.md +47 -0
- welt_io-0.1.0/SECURITY.md +9 -0
- welt_io-0.1.0/pyproject.toml +99 -0
- welt_io-0.1.0/src/welt_io/__init__.py +110 -0
- welt_io-0.1.0/src/welt_io/_version.py +24 -0
- welt_io-0.1.0/src/welt_io/py.typed +0 -0
- welt_io-0.1.0/tests/test_decode_file_blocks.py +110 -0
- welt_io-0.1.0/tests/test_renderable_events.py +137 -0
welt_io-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
We welcome contributions! Please follow these guidelines when submitting pull requests.
|
|
4
|
+
|
|
5
|
+
## Recommended setup
|
|
6
|
+
|
|
7
|
+
After cloning, enable the git hooks:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
mise run enable-git-hooks
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This wires `./validate.sh` to `pre-commit`, plus DCO and Conventional Commits checks to `commit-msg`, so the requirements below run automatically on each commit.
|
|
14
|
+
|
|
15
|
+
## Before Submitting a PR
|
|
16
|
+
|
|
17
|
+
### 1. Run Validation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
./validate.sh
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Runs the project's validation pipeline.
|
|
24
|
+
|
|
25
|
+
### 2. Sign Commits with DCO
|
|
26
|
+
|
|
27
|
+
All commits must be signed with DCO. Use `git commit -s` to sign your commits.
|
|
28
|
+
|
|
29
|
+
- Use your real name (no pseudonyms)
|
|
30
|
+
- All commits in a PR must be signed
|
|
31
|
+
- If the DCO check fails, see the check details page for instructions on fixing unsigned commits
|
|
32
|
+
|
|
33
|
+
For more about DCO: https://developercertificate.org/
|
|
34
|
+
|
|
35
|
+
### 3. Follow Conventional Commits
|
|
36
|
+
|
|
37
|
+
Commit subjects (first line) must follow `<type>(<scope>)?!?: <description>`. Allowed types: `feat`, `fix`, `chore`, `docs`. Append `!` for breaking changes.
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
feat: add dark-mode toggle
|
|
41
|
+
fix(auth): handle expired token
|
|
42
|
+
chore: bump dependencies
|
|
43
|
+
feat!: drop legacy API
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Other types (`refactor`, `ci`, `test`, etc.) are intentionally rejected — use `chore:` instead.
|
|
47
|
+
|
|
48
|
+
For more about Conventional Commits: https://www.conventionalcommits.org/
|
welt_io-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Takashi Iwamoto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
welt_io-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: welt-io
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent-side adapters for Welt's wire contract
|
|
5
|
+
Project-URL: Repository, https://github.com/iwamot/welt-io
|
|
6
|
+
Project-URL: Issues, https://github.com/iwamot/welt-io/issues
|
|
7
|
+
Author: Takashi Iwamoto
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agentcore,bedrock,slack,strands,welt
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pip-licenses==5.5.5; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest-cov==7.1.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest==9.1.1; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# welt-io
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/welt-io/)
|
|
29
|
+
[](https://pypi.org/project/welt-io/)
|
|
30
|
+
|
|
31
|
+
Agent-side adapters for [Welt](https://github.com/iwamot/welt)'s wire contract.
|
|
32
|
+
|
|
33
|
+
The wire between Welt and your agent is JSON, and plain Strands values do not fit it in either direction:
|
|
34
|
+
|
|
35
|
+
- **Inbound**, JSON cannot carry raw bytes, so Welt base64-encodes the `bytes` slot of the Converse image/document/video blocks it builds from Slack uploads. `decode_file_blocks` restores them before Strands (Bedrock Converse) sees the messages; without uploads it is a no-op.
|
|
36
|
+
- **Outbound**, raw `stream_async` events carry values that are not JSON-serializable (the Agent itself, UUIDs, traces), which the AgentCore Runtime SDK would degrade to a plain string on the SSE wire. `renderable_events` reduces the stream to the events Welt renders: text chunks (`data`), tool-use starts (`current_tool_use`), and tool completions (`tool_result`, slimmed to the toolUseId and status so tool output stays off the wire).
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv add welt-io
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from collections.abc import AsyncIterator
|
|
48
|
+
|
|
49
|
+
from bedrock_agentcore.runtime import BedrockAgentCoreApp
|
|
50
|
+
from strands import Agent
|
|
51
|
+
from welt_io import decode_file_blocks, renderable_events
|
|
52
|
+
|
|
53
|
+
app = BedrockAgentCoreApp()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.entrypoint
|
|
57
|
+
async def invoke(payload: dict) -> AsyncIterator[dict]:
|
|
58
|
+
messages = payload["messages"]
|
|
59
|
+
decode_file_blocks(messages) # base64 file bytes -> raw bytes, in place
|
|
60
|
+
agent = Agent()
|
|
61
|
+
# Reduce the stream to the JSON-serializable events Welt renders
|
|
62
|
+
async for event in renderable_events(agent.stream_async(messages)):
|
|
63
|
+
yield event
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
app.run()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
welt_io-0.1.0/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# welt-io
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/welt-io/)
|
|
4
|
+
[](https://pypi.org/project/welt-io/)
|
|
5
|
+
|
|
6
|
+
Agent-side adapters for [Welt](https://github.com/iwamot/welt)'s wire contract.
|
|
7
|
+
|
|
8
|
+
The wire between Welt and your agent is JSON, and plain Strands values do not fit it in either direction:
|
|
9
|
+
|
|
10
|
+
- **Inbound**, JSON cannot carry raw bytes, so Welt base64-encodes the `bytes` slot of the Converse image/document/video blocks it builds from Slack uploads. `decode_file_blocks` restores them before Strands (Bedrock Converse) sees the messages; without uploads it is a no-op.
|
|
11
|
+
- **Outbound**, raw `stream_async` events carry values that are not JSON-serializable (the Agent itself, UUIDs, traces), which the AgentCore Runtime SDK would degrade to a plain string on the SSE wire. `renderable_events` reduces the stream to the events Welt renders: text chunks (`data`), tool-use starts (`current_tool_use`), and tool completions (`tool_result`, slimmed to the toolUseId and status so tool output stays off the wire).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv add welt-io
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from collections.abc import AsyncIterator
|
|
23
|
+
|
|
24
|
+
from bedrock_agentcore.runtime import BedrockAgentCoreApp
|
|
25
|
+
from strands import Agent
|
|
26
|
+
from welt_io import decode_file_blocks, renderable_events
|
|
27
|
+
|
|
28
|
+
app = BedrockAgentCoreApp()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.entrypoint
|
|
32
|
+
async def invoke(payload: dict) -> AsyncIterator[dict]:
|
|
33
|
+
messages = payload["messages"]
|
|
34
|
+
decode_file_blocks(messages) # base64 file bytes -> raw bytes, in place
|
|
35
|
+
agent = Agent()
|
|
36
|
+
# Reduce the stream to the JSON-serializable events Welt renders
|
|
37
|
+
async for event in renderable_events(agent.stream_async(messages)):
|
|
38
|
+
yield event
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
app.run()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Disclaimer
|
|
4
|
+
|
|
5
|
+
This software is provided "as is" without warranty. Security issues will be addressed on a best-effort basis.
|
|
6
|
+
|
|
7
|
+
## Reporting a Vulnerability
|
|
8
|
+
|
|
9
|
+
Please report security vulnerabilities through GitHub's Security Advisory "Report a Vulnerability" feature.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "welt-io"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Agent-side adapters for Welt's wire contract"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Takashi Iwamoto" }]
|
|
9
|
+
keywords = ["welt", "slack", "agentcore", "bedrock", "strands"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
"Topic :: Software Development",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Repository = "https://github.com/iwamot/welt-io"
|
|
24
|
+
Issues = "https://github.com/iwamot/welt-io/issues"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = [
|
|
28
|
+
"pip-licenses==5.5.5",
|
|
29
|
+
"pytest==9.1.1",
|
|
30
|
+
"pytest-cov==7.1.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.version]
|
|
38
|
+
source = "vcs"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.hooks.vcs]
|
|
41
|
+
version-file = "src/welt_io/_version.py"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/welt_io"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
exclude = [
|
|
48
|
+
"/.codecov.yml",
|
|
49
|
+
"/.github",
|
|
50
|
+
"/compatibility.sh",
|
|
51
|
+
"/mise.toml",
|
|
52
|
+
"/uv.lock",
|
|
53
|
+
"/validate.sh",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.uv]
|
|
57
|
+
exclude-newer = "1 day"
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
include = ["*.py", "src/**/*.py", "tests/**/*.py"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
# Reference: https://pydevtools.com/handbook/how-to/how-to-configure-recommended-ruff-defaults/
|
|
64
|
+
extend-select = [
|
|
65
|
+
"F", # Pyflakes rules
|
|
66
|
+
"W", # PyCodeStyle warnings
|
|
67
|
+
# "E", # PyCodeStyle errors
|
|
68
|
+
"I", # Sort imports properly
|
|
69
|
+
"UP", # Warn if certain things can changed due to newer Python versions
|
|
70
|
+
"C4", # Catch incorrect use of comprehensions, dict, list, etc
|
|
71
|
+
"FA", # Enforce from __future__ import annotations
|
|
72
|
+
"ISC", # Good use of string concatenation
|
|
73
|
+
"ICN", # Use common import conventions
|
|
74
|
+
"RET", # Good return practices
|
|
75
|
+
# "SIM", # Common simplification rules
|
|
76
|
+
"TID", # Some good import practices
|
|
77
|
+
# "TC", # Enforce importing certain types in a TYPE_CHECKING block
|
|
78
|
+
"PTH", # Use pathlib instead of os.path
|
|
79
|
+
"TD", # Be diligent with TODO comments
|
|
80
|
+
# "NPY", # Some numpy-specific things
|
|
81
|
+
# Not in the reference URL
|
|
82
|
+
"S", # flake8-bandit (security)
|
|
83
|
+
]
|
|
84
|
+
ignore = [
|
|
85
|
+
"S101", # Allow assert in tests (pytest standard practice)
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
[tool.ty.src]
|
|
89
|
+
include = ["src", "tests"]
|
|
90
|
+
|
|
91
|
+
[tool.pytest.ini_options]
|
|
92
|
+
testpaths = ["tests"]
|
|
93
|
+
|
|
94
|
+
[tool.coverage.run]
|
|
95
|
+
source = ["welt_io"]
|
|
96
|
+
branch = true
|
|
97
|
+
|
|
98
|
+
[tool.coverage.report]
|
|
99
|
+
show_missing = true
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Adapters for the two directions of Welt's wire contract.
|
|
2
|
+
|
|
3
|
+
The wire between Welt and the agent is JSON, and plain Strands values do not
|
|
4
|
+
fit it in either direction:
|
|
5
|
+
|
|
6
|
+
- Inbound, JSON cannot carry raw bytes, so Welt base64-encodes the `bytes`
|
|
7
|
+
slot of the Converse image/document/video blocks it builds from Slack
|
|
8
|
+
uploads. `decode_file_blocks` restores them before Strands (Bedrock
|
|
9
|
+
Converse) sees the messages; without uploads it is a no-op.
|
|
10
|
+
- Outbound, raw `stream_async` events carry values that are not
|
|
11
|
+
JSON-serializable (the Agent itself, UUIDs, traces), which the AgentCore
|
|
12
|
+
Runtime SDK would degrade to a plain string on the SSE wire.
|
|
13
|
+
`renderable_events` reduces the stream to the events Welt renders.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
from collections.abc import AsyncIterator
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
except ImportError:
|
|
22
|
+
__version__ = "0.0.0+unknown"
|
|
23
|
+
|
|
24
|
+
__all__ = ["decode_file_blocks", "renderable_events"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def decode_file_blocks(messages: list) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Decode base64 image/document/video bytes back to raw bytes, in place.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
messages (list): The Converse-shaped messages from Welt's payload.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
None
|
|
36
|
+
"""
|
|
37
|
+
for message in messages:
|
|
38
|
+
if not isinstance(message, dict):
|
|
39
|
+
continue
|
|
40
|
+
content = message.get("content")
|
|
41
|
+
if not isinstance(content, list):
|
|
42
|
+
continue
|
|
43
|
+
for block in content:
|
|
44
|
+
if not isinstance(block, dict):
|
|
45
|
+
continue
|
|
46
|
+
for key in ("image", "document", "video"):
|
|
47
|
+
media = block.get(key)
|
|
48
|
+
if not isinstance(media, dict):
|
|
49
|
+
continue
|
|
50
|
+
source = media.get("source")
|
|
51
|
+
if isinstance(source, dict) and isinstance(source.get("bytes"), str):
|
|
52
|
+
source["bytes"] = base64.b64decode(source["bytes"])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def renderable_events(events: AsyncIterator[dict]) -> AsyncIterator[dict]:
|
|
56
|
+
"""
|
|
57
|
+
Reduce Strands `stream_async` events to the subset Welt renders.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
events (AsyncIterator[dict]): Raw `stream_async` events.
|
|
61
|
+
|
|
62
|
+
Yields:
|
|
63
|
+
dict: A `data` event per text chunk, a `current_tool_use` event per
|
|
64
|
+
tool-use update, and a `tool_result` event — slimmed to the
|
|
65
|
+
toolUseId and status, so tool output (arbitrarily large, possibly
|
|
66
|
+
raw bytes) stays off the wire — per completed tool.
|
|
67
|
+
"""
|
|
68
|
+
async for event in events:
|
|
69
|
+
if "data" in event:
|
|
70
|
+
yield {"data": event["data"]}
|
|
71
|
+
elif "current_tool_use" in event:
|
|
72
|
+
yield {"current_tool_use": event["current_tool_use"]}
|
|
73
|
+
elif "message" in event:
|
|
74
|
+
for tool_result in _tool_results(event["message"]):
|
|
75
|
+
yield {"tool_result": tool_result}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _tool_results(message: object) -> list[dict]:
|
|
79
|
+
"""
|
|
80
|
+
Extract slimmed toolResult entries from a Strands message event.
|
|
81
|
+
|
|
82
|
+
Strands adds tool results to the conversation as a message whose content
|
|
83
|
+
blocks each carry a `toolResult`; model messages carry none, so they
|
|
84
|
+
yield an empty list.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
message (object): The `message` value of a stream event.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
list[dict]: One `{"toolUseId", "status"}` entry per tool result.
|
|
91
|
+
"""
|
|
92
|
+
if not isinstance(message, dict):
|
|
93
|
+
return []
|
|
94
|
+
content = message.get("content")
|
|
95
|
+
if not isinstance(content, list):
|
|
96
|
+
return []
|
|
97
|
+
results: list[dict] = []
|
|
98
|
+
for block in content:
|
|
99
|
+
if not isinstance(block, dict):
|
|
100
|
+
continue
|
|
101
|
+
tool_result = block.get("toolResult")
|
|
102
|
+
if not isinstance(tool_result, dict):
|
|
103
|
+
continue
|
|
104
|
+
results.append(
|
|
105
|
+
{
|
|
106
|
+
"toolUseId": tool_result.get("toolUseId"),
|
|
107
|
+
"status": tool_result.get("status"),
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
return results
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
File without changes
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
|
|
3
|
+
from welt_io import decode_file_blocks
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def encoded(raw: bytes) -> str:
|
|
7
|
+
return base64.b64encode(raw).decode()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_decodes_image_document_and_video_blocks() -> None:
|
|
11
|
+
image_source: dict[str, object] = {"bytes": encoded(b"img")}
|
|
12
|
+
document_source: dict[str, object] = {"bytes": encoded(b"doc")}
|
|
13
|
+
video_source: dict[str, object] = {"bytes": encoded(b"vid")}
|
|
14
|
+
messages = [
|
|
15
|
+
{
|
|
16
|
+
"role": "user",
|
|
17
|
+
"content": [
|
|
18
|
+
{"image": {"format": "png", "source": image_source}},
|
|
19
|
+
{"document": {"format": "pdf", "source": document_source}},
|
|
20
|
+
{"video": {"format": "mp4", "source": video_source}},
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
decode_file_blocks(messages)
|
|
26
|
+
|
|
27
|
+
assert image_source["bytes"] == b"img"
|
|
28
|
+
assert document_source["bytes"] == b"doc"
|
|
29
|
+
assert video_source["bytes"] == b"vid"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_decodes_across_multiple_messages() -> None:
|
|
33
|
+
first_source: dict[str, object] = {"bytes": encoded(b"a")}
|
|
34
|
+
second_source: dict[str, object] = {"bytes": encoded(b"b")}
|
|
35
|
+
messages = [
|
|
36
|
+
{"role": "user", "content": [{"image": {"source": first_source}}]},
|
|
37
|
+
{"role": "user", "content": [{"image": {"source": second_source}}]},
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
decode_file_blocks(messages)
|
|
41
|
+
|
|
42
|
+
assert first_source["bytes"] == b"a"
|
|
43
|
+
assert second_source["bytes"] == b"b"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_leaves_text_blocks_alone() -> None:
|
|
47
|
+
messages = [{"role": "user", "content": [{"text": "hello"}]}]
|
|
48
|
+
|
|
49
|
+
decode_file_blocks(messages)
|
|
50
|
+
|
|
51
|
+
assert messages == [{"role": "user", "content": [{"text": "hello"}]}]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_no_op_on_empty_messages() -> None:
|
|
55
|
+
messages: list = []
|
|
56
|
+
|
|
57
|
+
decode_file_blocks(messages)
|
|
58
|
+
|
|
59
|
+
assert messages == []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_skips_non_dict_message() -> None:
|
|
63
|
+
messages = ["not a dict"]
|
|
64
|
+
|
|
65
|
+
decode_file_blocks(messages)
|
|
66
|
+
|
|
67
|
+
assert messages == ["not a dict"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_skips_non_list_content() -> None:
|
|
71
|
+
messages = [{"role": "user", "content": "not a list"}]
|
|
72
|
+
|
|
73
|
+
decode_file_blocks(messages)
|
|
74
|
+
|
|
75
|
+
assert messages == [{"role": "user", "content": "not a list"}]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_skips_non_dict_block() -> None:
|
|
79
|
+
messages = [{"role": "user", "content": ["not a dict"]}]
|
|
80
|
+
|
|
81
|
+
decode_file_blocks(messages)
|
|
82
|
+
|
|
83
|
+
assert messages == [{"role": "user", "content": ["not a dict"]}]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_skips_non_dict_media() -> None:
|
|
87
|
+
messages = [{"role": "user", "content": [{"image": "not a dict"}]}]
|
|
88
|
+
|
|
89
|
+
decode_file_blocks(messages)
|
|
90
|
+
|
|
91
|
+
assert messages == [{"role": "user", "content": [{"image": "not a dict"}]}]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_skips_non_dict_source() -> None:
|
|
95
|
+
messages = [{"role": "user", "content": [{"image": {"source": "not a dict"}}]}]
|
|
96
|
+
|
|
97
|
+
decode_file_blocks(messages)
|
|
98
|
+
|
|
99
|
+
assert messages == [
|
|
100
|
+
{"role": "user", "content": [{"image": {"source": "not a dict"}}]}
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_skips_bytes_that_are_not_str() -> None:
|
|
105
|
+
source: dict[str, object] = {"bytes": b"raw"}
|
|
106
|
+
messages = [{"role": "user", "content": [{"image": {"source": source}}]}]
|
|
107
|
+
|
|
108
|
+
decode_file_blocks(messages)
|
|
109
|
+
|
|
110
|
+
assert source["bytes"] == b"raw"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import AsyncIterator
|
|
3
|
+
|
|
4
|
+
from welt_io import renderable_events
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def rendered(events: list) -> list[dict]:
|
|
8
|
+
async def source() -> AsyncIterator[dict]:
|
|
9
|
+
for event in events:
|
|
10
|
+
yield event
|
|
11
|
+
|
|
12
|
+
async def gather() -> list[dict]:
|
|
13
|
+
return [event async for event in renderable_events(source())]
|
|
14
|
+
|
|
15
|
+
return asyncio.run(gather())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_data_event_is_reduced_to_its_data_key() -> None:
|
|
19
|
+
events = [{"data": "hello", "delta": {"text": "hello"}, "agent": object()}]
|
|
20
|
+
|
|
21
|
+
assert rendered(events) == [{"data": "hello"}]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_current_tool_use_event_is_reduced_to_its_key() -> None:
|
|
25
|
+
tool_use = {"toolUseId": "id-1", "name": "current_time", "input": {}}
|
|
26
|
+
events = [{"current_tool_use": tool_use, "agent": object()}]
|
|
27
|
+
|
|
28
|
+
assert rendered(events) == [{"current_tool_use": tool_use}]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_data_takes_precedence_over_current_tool_use() -> None:
|
|
32
|
+
events = [{"data": "hello", "current_tool_use": {"toolUseId": "id-1"}}]
|
|
33
|
+
|
|
34
|
+
assert rendered(events) == [{"data": "hello"}]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_tool_result_message_is_slimmed_to_id_and_status() -> None:
|
|
38
|
+
events = [
|
|
39
|
+
{
|
|
40
|
+
"message": {
|
|
41
|
+
"role": "user",
|
|
42
|
+
"content": [
|
|
43
|
+
{
|
|
44
|
+
"toolResult": {
|
|
45
|
+
"toolUseId": "id-1",
|
|
46
|
+
"status": "success",
|
|
47
|
+
"content": [{"text": "arbitrarily large output"}],
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
assert rendered(events) == [
|
|
56
|
+
{"tool_result": {"toolUseId": "id-1", "status": "success"}}
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_yields_one_tool_result_per_block() -> None:
|
|
61
|
+
events = [
|
|
62
|
+
{
|
|
63
|
+
"message": {
|
|
64
|
+
"role": "user",
|
|
65
|
+
"content": [
|
|
66
|
+
{"toolResult": {"toolUseId": "id-1", "status": "success"}},
|
|
67
|
+
{"toolResult": {"toolUseId": "id-2", "status": "error"}},
|
|
68
|
+
],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
assert rendered(events) == [
|
|
74
|
+
{"tool_result": {"toolUseId": "id-1", "status": "success"}},
|
|
75
|
+
{"tool_result": {"toolUseId": "id-2", "status": "error"}},
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_missing_tool_result_fields_become_none() -> None:
|
|
80
|
+
events = [{"message": {"role": "user", "content": [{"toolResult": {}}]}}]
|
|
81
|
+
|
|
82
|
+
assert rendered(events) == [{"tool_result": {"toolUseId": None, "status": None}}]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_model_message_without_tool_results_yields_nothing() -> None:
|
|
86
|
+
events = [{"message": {"role": "assistant", "content": [{"text": "hi"}]}}]
|
|
87
|
+
|
|
88
|
+
assert rendered(events) == []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_non_dict_message_yields_nothing() -> None:
|
|
92
|
+
assert rendered([{"message": "not a dict"}]) == []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_non_list_message_content_yields_nothing() -> None:
|
|
96
|
+
assert rendered([{"message": {"role": "user", "content": "not a list"}}]) == []
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_non_dict_content_block_is_skipped() -> None:
|
|
100
|
+
assert rendered([{"message": {"role": "user", "content": ["not a dict"]}}]) == []
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_non_dict_tool_result_is_skipped() -> None:
|
|
104
|
+
events = [{"message": {"role": "user", "content": [{"toolResult": "not a dict"}]}}]
|
|
105
|
+
|
|
106
|
+
assert rendered(events) == []
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_unrenderable_events_are_dropped() -> None:
|
|
110
|
+
events = [
|
|
111
|
+
{"init_event_loop": True},
|
|
112
|
+
{"delta": {"text": "chunk"}},
|
|
113
|
+
{"result": object()},
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
assert rendered(events) == []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_stream_order_is_preserved() -> None:
|
|
120
|
+
events = [
|
|
121
|
+
{"data": "a"},
|
|
122
|
+
{"current_tool_use": {"toolUseId": "id-1"}},
|
|
123
|
+
{
|
|
124
|
+
"message": {
|
|
125
|
+
"role": "user",
|
|
126
|
+
"content": [{"toolResult": {"toolUseId": "id-1", "status": "success"}}],
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
{"data": "b"},
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
assert rendered(events) == [
|
|
133
|
+
{"data": "a"},
|
|
134
|
+
{"current_tool_use": {"toolUseId": "id-1"}},
|
|
135
|
+
{"tool_result": {"toolUseId": "id-1", "status": "success"}},
|
|
136
|
+
{"data": "b"},
|
|
137
|
+
]
|