@welt-io/strands 0.0.1
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.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/index.d.ts +207 -0
- package/dist/index.js +487 -0
- package/package.json +52 -0
package/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.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @welt-io/strands
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@welt-io/strands)
|
|
4
|
+
[](https://www.npmjs.com/package/@welt-io/strands)
|
|
5
|
+
|
|
6
|
+
The [Strands Agents](https://strandsagents.com/) (TypeScript) adapter for [Welt](https://github.com/iwamot/welt)'s wire contract — one of Welt's [agent-side adapters](https://github.com/iwamot/welt#agent-side-adapters), and the TypeScript counterpart of [welt-io](https://github.com/iwamot/welt-io), the Python Strands adapter.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @welt-io/strands
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
See [`examples/agent`](examples/agent) — the smallest complete agent built on this package (text streaming, tool use, file output, file input, and a human-approval tool). The sections below explain the adapters it wires in.
|
|
17
|
+
|
|
18
|
+
## API
|
|
19
|
+
|
|
20
|
+
The wire between Welt and the agent is JSON, specified by [Welt's wire contract](https://github.com/iwamot/welt/blob/main/docs/wire.md). Strands speaks nearly the same shapes, but not exactly, in either direction. Two functions adapt the inbound payload, three the outbound stream.
|
|
21
|
+
|
|
22
|
+
### Inbound
|
|
23
|
+
|
|
24
|
+
#### `decodeMessages(messages)`
|
|
25
|
+
|
|
26
|
+
Turns Welt's Converse-shaped messages — built from the Slack thread, file bytes base64-encoded — into the messages Strands consumes. The block shapes already match; what changes is the encoding: the image/document/video bytes decode to the raw `Uint8Array` the SDK holds, and the wire's `three_gp` video token becomes the SDK's `3gp`. Malformed entries are skipped. The result feeds `Agent.stream()`:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const agent = new Agent({ tools });
|
|
30
|
+
const stream = agent.stream(decodeMessages(payload.messages));
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
#### `decodeInterruptResponses(responses)`
|
|
34
|
+
|
|
35
|
+
Turns Welt's resume payload — a mapping of interrupt id to the answer a human chose — into the `interruptResponse` content items Strands resumes from. The returned list feeds `Agent.stream()` on the interrupted `Agent` instance directly (see the [example agent](examples/agent) for how the host app keeps that instance around).
|
|
36
|
+
|
|
37
|
+
### Outbound
|
|
38
|
+
|
|
39
|
+
#### `renderableEvents(events)`
|
|
40
|
+
|
|
41
|
+
Reduces the events of `Agent.stream()` — objects Welt does not render — to the events Welt renders:
|
|
42
|
+
|
|
43
|
+
| Strands emits | On the wire | In the Slack thread |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| Text deltas | `data` | The streamed reply |
|
|
46
|
+
| Tool-use starts and tool results | `current_tool_use` / `tool_result` | "Using tool" indicators (tool output stays off the wire) |
|
|
47
|
+
| Image/document/video blocks a tool returns or the assistant message carries | `file` | An uploaded file ([size limits](https://github.com/iwamot/welt/blob/main/docs/wire.md#limits)) |
|
|
48
|
+
| Interrupts pending in the final result | `interrupt` | Buttons and/or a text field |
|
|
49
|
+
|
|
50
|
+
A run that stops for human input ends its stream with one `interrupt` event per pending interrupt — a faithful copy of the interrupt's id, name, and reason, the reason passed through unmodified since interpreting it is the renderer's job. Agents that do not interrupt see no change. To ask for human input from a tool, call `ToolContext.interrupt` with a reason built by `interruptReason` below; on resume, the same call returns the human's answer.
|
|
51
|
+
|
|
52
|
+
#### `fileEvent(name, data)`
|
|
53
|
+
|
|
54
|
+
Builds the same `file` event from a filename and raw bytes, for attaching arbitrary files of your own — yield it from the host app alongside the reduced stream. From inside a tool, no helper is needed: return an image/document/video content block and `renderableEvents` turns it into a `file` event (the [example agent](examples/agent)'s `attach_sample_file` shows this).
|
|
55
|
+
|
|
56
|
+
#### `interruptReason(message, options, input)`
|
|
57
|
+
|
|
58
|
+
Builds the structured reason Welt renders as a message with the specified widgets — choice buttons (`options`), a free-text field (`input`), or both. The specs are [the wire's own shapes](https://github.com/iwamot/welt/blob/main/docs/wire.md#interrupt); omitted fields keep Welt's defaults, and a typo becomes an immediate `TypeError` instead of a silent fallback to Welt's default rendering:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const answer = context.interrupt<string>({
|
|
62
|
+
name: "prod-deploy-approval",
|
|
63
|
+
reason: interruptReason(
|
|
64
|
+
"Deploy to prod?",
|
|
65
|
+
[
|
|
66
|
+
{ value: "y", label: "Deploy", style: "primary" },
|
|
67
|
+
{ value: "n", label: "Cancel" },
|
|
68
|
+
],
|
|
69
|
+
{ label: "Or tell me what to do instead" },
|
|
70
|
+
),
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
[Welt's Interrupts doc](https://github.com/iwamot/welt/blob/main/docs/interrupts.md) covers the Slack side: how each reason renders, who can answer, multiple questions, and expiry.
|
|
75
|
+
|
|
76
|
+
## Supported Versions
|
|
77
|
+
|
|
78
|
+
Welt releases first; @welt-io/strands follows, mirroring the minor version. While both are 0.x, a @welt-io/strands 0.Y release supports Welt v0.Y — other combinations may work, but come with no guarantee.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Strands Agents (TypeScript) adapter for Welt's wire contract.
|
|
3
|
+
*
|
|
4
|
+
* Welt (https://github.com/iwamot/welt) drives an agent over plain JSON:
|
|
5
|
+
* Converse-shaped `messages` (or `interrupt_responses` answering an
|
|
6
|
+
* interrupted run) in, a stream of renderable events out. Strands speaks
|
|
7
|
+
* nearly the same shapes, but not exactly: JSON cannot carry the raw
|
|
8
|
+
* bytes the SDK's file blocks hold, one video format token differs, and
|
|
9
|
+
* `Agent.stream()` yields event objects Welt does not render. Each
|
|
10
|
+
* function here adapts one piece, keeping the host app a thin loop
|
|
11
|
+
* around `Agent.stream()`.
|
|
12
|
+
*/
|
|
13
|
+
/** An image format token of the SDK's image blocks. */
|
|
14
|
+
export type ImageFormat = "gif" | "jpeg" | "jpg" | "png" | "webp";
|
|
15
|
+
/** A document format token of the SDK's document blocks. */
|
|
16
|
+
export type DocumentFormat = "csv" | "doc" | "docx" | "html" | "json" | "md" | "pdf" | "txt" | "xls" | "xlsx" | "xml";
|
|
17
|
+
/** A video format token of the SDK's video blocks. */
|
|
18
|
+
export type VideoFormat = "3gp" | "flv" | "mkv" | "mov" | "mp4" | "mpeg" | "mpg" | "webm" | "wmv";
|
|
19
|
+
/** A text block of a decoded message. */
|
|
20
|
+
export interface DecodedTextBlock {
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
/** An image block of a decoded message; the bytes are raw. */
|
|
24
|
+
export interface DecodedImageBlock {
|
|
25
|
+
image: {
|
|
26
|
+
format: ImageFormat;
|
|
27
|
+
source: {
|
|
28
|
+
bytes: Uint8Array;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** A document block of a decoded message; the bytes are raw. */
|
|
33
|
+
export interface DecodedDocumentBlock {
|
|
34
|
+
document: {
|
|
35
|
+
name: string;
|
|
36
|
+
format: DocumentFormat;
|
|
37
|
+
source: {
|
|
38
|
+
bytes: Uint8Array;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** A video block of a decoded message; the bytes are raw. */
|
|
43
|
+
export interface DecodedVideoBlock {
|
|
44
|
+
video: {
|
|
45
|
+
format: VideoFormat;
|
|
46
|
+
source: {
|
|
47
|
+
bytes: Uint8Array;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** A content block of a decoded user message. */
|
|
52
|
+
export type DecodedUserBlock = DecodedTextBlock | DecodedImageBlock | DecodedDocumentBlock | DecodedVideoBlock;
|
|
53
|
+
/** A Strands message decoded from Welt's Converse-shaped payload. */
|
|
54
|
+
export type DecodedMessage = {
|
|
55
|
+
role: "user";
|
|
56
|
+
content: DecodedUserBlock[];
|
|
57
|
+
} | {
|
|
58
|
+
role: "assistant";
|
|
59
|
+
content: DecodedTextBlock[];
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Decode Welt's Converse-shaped messages into the messages Strands consumes.
|
|
63
|
+
*
|
|
64
|
+
* Strands consumes Welt's messages nearly as they are: the block shapes
|
|
65
|
+
* match, but the image/document/video bytes arrive base64-encoded — JSON
|
|
66
|
+
* cannot carry raw bytes — where the SDK holds a `Uint8Array`, and the
|
|
67
|
+
* wire's `three_gp` video token is `3gp` in the SDK. This walks the
|
|
68
|
+
* payload's `messages` value and rebuilds each message with raw bytes
|
|
69
|
+
* and SDK format tokens. Malformed entries are skipped, since they come
|
|
70
|
+
* from the wire rather than the developer; messages left with no blocks
|
|
71
|
+
* are dropped. The result feeds `new Agent({ messages })`.
|
|
72
|
+
*
|
|
73
|
+
* @param messages - The `messages` value of Welt's payload.
|
|
74
|
+
* @returns Messages for the `Agent` constructor.
|
|
75
|
+
*/
|
|
76
|
+
export declare function decodeMessages(messages: unknown): DecodedMessage[];
|
|
77
|
+
/** One decoded interrupt answer as a Strands resume content block. */
|
|
78
|
+
export interface DecodedInterruptResponse {
|
|
79
|
+
interruptResponse: {
|
|
80
|
+
interruptId: string;
|
|
81
|
+
response: string;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Decode Welt's interrupt answers into Strands' resume input.
|
|
86
|
+
*
|
|
87
|
+
* Welt resumes an interrupted run with a payload mapping each interrupt
|
|
88
|
+
* id to the answer a human chose in the thread. Strands resumes from a
|
|
89
|
+
* list of `interruptResponse` content items; the returned list feeds
|
|
90
|
+
* `Agent.stream()` directly.
|
|
91
|
+
*
|
|
92
|
+
* @param responses - The `interrupt_responses` value of Welt's payload.
|
|
93
|
+
* @returns One `interruptResponse` item per answered interrupt, in
|
|
94
|
+
* payload order.
|
|
95
|
+
*/
|
|
96
|
+
export declare function decodeInterruptResponses(responses: unknown): DecodedInterruptResponse[];
|
|
97
|
+
/** A `file` wire event: a filename plus base64 bytes Welt uploads to Slack. */
|
|
98
|
+
export interface FileEvent {
|
|
99
|
+
file: {
|
|
100
|
+
name: string;
|
|
101
|
+
bytes: string;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Build a `file` wire event, which Welt uploads to the Slack thread.
|
|
106
|
+
*
|
|
107
|
+
* `renderableEvents` emits these for the files a tool or the model
|
|
108
|
+
* generates; this builds the same event from arbitrary bytes, for agents
|
|
109
|
+
* that attach files of their own alongside the reduced stream — yield it
|
|
110
|
+
* from the host app.
|
|
111
|
+
*
|
|
112
|
+
* @param name - The upload filename, extension included.
|
|
113
|
+
* @param data - The raw file bytes.
|
|
114
|
+
* @returns The `file` event (name plus base64 bytes).
|
|
115
|
+
* @throws TypeError if the name is empty (Welt drops a nameless file).
|
|
116
|
+
*/
|
|
117
|
+
export declare function fileEvent(name: string, data: Uint8Array): FileEvent;
|
|
118
|
+
/** A button of a structured interrupt reason. */
|
|
119
|
+
export type InterruptOption = {
|
|
120
|
+
value: string;
|
|
121
|
+
label?: string;
|
|
122
|
+
style?: "primary" | "danger";
|
|
123
|
+
};
|
|
124
|
+
/** The free-text field of a structured interrupt reason. */
|
|
125
|
+
export type InterruptInput = {
|
|
126
|
+
label?: string;
|
|
127
|
+
multiline?: boolean;
|
|
128
|
+
};
|
|
129
|
+
/** The structured interrupt reason shape Welt renders as widgets. */
|
|
130
|
+
export type InterruptReason = {
|
|
131
|
+
message: string;
|
|
132
|
+
options?: InterruptOption[];
|
|
133
|
+
input?: InterruptInput;
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Build an interrupt reason that Welt renders as the specified widgets.
|
|
137
|
+
*
|
|
138
|
+
* Welt renders this shape as `message` followed by one button per option
|
|
139
|
+
* (`options`), a free-text field whose submitted text becomes the
|
|
140
|
+
* interrupt's response (`input`), or both — whichever answer comes
|
|
141
|
+
* first, a pressed button or the submitted text, settles the question.
|
|
142
|
+
* Both widget specs are the wire's own shapes; building them through
|
|
143
|
+
* this helper turns a typo into an immediate TypeError instead of a
|
|
144
|
+
* silent fallback to Welt's default rendering.
|
|
145
|
+
*
|
|
146
|
+
* @param message - The text Welt shows above the widgets.
|
|
147
|
+
* @param options - One entry per button: a required `value` (what the
|
|
148
|
+
* interrupting tool receives as the response when the button is
|
|
149
|
+
* pressed), an optional `label` (the button text; omitted, Welt shows
|
|
150
|
+
* the value), and an optional `style` ("primary" or "danger").
|
|
151
|
+
* @param input - The free-text field: an optional `label` (the field's
|
|
152
|
+
* label) and an optional `multiline` (whether the field accepts
|
|
153
|
+
* multiple lines) — `{}` takes Welt's defaults for both. Omitted, no
|
|
154
|
+
* field renders.
|
|
155
|
+
* @returns The reason to pass to `ToolContext.interrupt`.
|
|
156
|
+
* @throws TypeError if the message is empty, neither options nor input
|
|
157
|
+
* is given, or a widget spec is off — an unknown key, a missing value,
|
|
158
|
+
* an empty or non-string value/label, a style that is not "primary" or
|
|
159
|
+
* "danger", or a non-boolean multiline.
|
|
160
|
+
*/
|
|
161
|
+
export declare function interruptReason(message: string, options?: readonly InterruptOption[], input?: InterruptInput): InterruptReason;
|
|
162
|
+
/** A `data` wire event: one text chunk of the reply. */
|
|
163
|
+
export interface TextEvent {
|
|
164
|
+
data: string;
|
|
165
|
+
}
|
|
166
|
+
/** A `current_tool_use` wire event: a tool call started. */
|
|
167
|
+
export interface ToolUseEvent {
|
|
168
|
+
current_tool_use: {
|
|
169
|
+
toolUseId: string | null;
|
|
170
|
+
name: string | null;
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/** A `tool_result` wire event: a tool call finished. */
|
|
174
|
+
export interface ToolResultEvent {
|
|
175
|
+
tool_result: {
|
|
176
|
+
toolUseId: string | null;
|
|
177
|
+
status: "success" | "error";
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** An `interrupt` wire event: the run paused for a human answer. */
|
|
181
|
+
export interface InterruptEvent {
|
|
182
|
+
interrupt: {
|
|
183
|
+
id: string;
|
|
184
|
+
name: string;
|
|
185
|
+
reason: unknown;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** An event of the wire's renderable subset. */
|
|
189
|
+
export type RenderableEvent = TextEvent | ToolUseEvent | ToolResultEvent | FileEvent | InterruptEvent;
|
|
190
|
+
/**
|
|
191
|
+
* Reduce Strands `Agent.stream()` events to the subset Welt renders.
|
|
192
|
+
*
|
|
193
|
+
* Iterates the events of `Agent.stream()` and yields the wire's
|
|
194
|
+
* renderable subset: text chunks (`data`), tool-use indicators
|
|
195
|
+
* (`current_tool_use` / `tool_result`, slimmed so tool output stays off
|
|
196
|
+
* the wire), generated files (`file` — one per image, document, or video
|
|
197
|
+
* block a tool result or the assistant message carries, named after the
|
|
198
|
+
* block's name or kind plus the format as extension), and interrupts
|
|
199
|
+
* (`interrupt` — when the run stops for human input, one per pending
|
|
200
|
+
* interrupt from the stream's final result, its id, name, and reason,
|
|
201
|
+
* the reason passed through unmodified since interpreting a reason is
|
|
202
|
+
* the renderer's job). Everything else is dropped.
|
|
203
|
+
*
|
|
204
|
+
* @param events - The events of `Agent.stream()`.
|
|
205
|
+
* @yields The renderable wire events, in stream order.
|
|
206
|
+
*/
|
|
207
|
+
export declare function renderableEvents(events: AsyncIterable<unknown>): AsyncGenerator<RenderableEvent, void, undefined>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Strands Agents (TypeScript) adapter for Welt's wire contract.
|
|
3
|
+
*
|
|
4
|
+
* Welt (https://github.com/iwamot/welt) drives an agent over plain JSON:
|
|
5
|
+
* Converse-shaped `messages` (or `interrupt_responses` answering an
|
|
6
|
+
* interrupted run) in, a stream of renderable events out. Strands speaks
|
|
7
|
+
* nearly the same shapes, but not exactly: JSON cannot carry the raw
|
|
8
|
+
* bytes the SDK's file blocks hold, one video format token differs, and
|
|
9
|
+
* `Agent.stream()` yields event objects Welt does not render. Each
|
|
10
|
+
* function here adapts one piece, keeping the host app a thin loop
|
|
11
|
+
* around `Agent.stream()`.
|
|
12
|
+
*/
|
|
13
|
+
import { Buffer } from "node:buffer";
|
|
14
|
+
const IMAGE_FORMATS = new Set([
|
|
15
|
+
"gif",
|
|
16
|
+
"jpeg",
|
|
17
|
+
"jpg",
|
|
18
|
+
"png",
|
|
19
|
+
"webp",
|
|
20
|
+
]);
|
|
21
|
+
const DOCUMENT_FORMATS = new Set([
|
|
22
|
+
"csv",
|
|
23
|
+
"doc",
|
|
24
|
+
"docx",
|
|
25
|
+
"html",
|
|
26
|
+
"json",
|
|
27
|
+
"md",
|
|
28
|
+
"pdf",
|
|
29
|
+
"txt",
|
|
30
|
+
"xls",
|
|
31
|
+
"xlsx",
|
|
32
|
+
"xml",
|
|
33
|
+
]);
|
|
34
|
+
const VIDEO_FORMATS = new Set([
|
|
35
|
+
"3gp",
|
|
36
|
+
"flv",
|
|
37
|
+
"mkv",
|
|
38
|
+
"mov",
|
|
39
|
+
"mp4",
|
|
40
|
+
"mpeg",
|
|
41
|
+
"mpg",
|
|
42
|
+
"webm",
|
|
43
|
+
"wmv",
|
|
44
|
+
]);
|
|
45
|
+
/**
|
|
46
|
+
* Decode Welt's Converse-shaped messages into the messages Strands consumes.
|
|
47
|
+
*
|
|
48
|
+
* Strands consumes Welt's messages nearly as they are: the block shapes
|
|
49
|
+
* match, but the image/document/video bytes arrive base64-encoded — JSON
|
|
50
|
+
* cannot carry raw bytes — where the SDK holds a `Uint8Array`, and the
|
|
51
|
+
* wire's `three_gp` video token is `3gp` in the SDK. This walks the
|
|
52
|
+
* payload's `messages` value and rebuilds each message with raw bytes
|
|
53
|
+
* and SDK format tokens. Malformed entries are skipped, since they come
|
|
54
|
+
* from the wire rather than the developer; messages left with no blocks
|
|
55
|
+
* are dropped. The result feeds `new Agent({ messages })`.
|
|
56
|
+
*
|
|
57
|
+
* @param messages - The `messages` value of Welt's payload.
|
|
58
|
+
* @returns Messages for the `Agent` constructor.
|
|
59
|
+
*/
|
|
60
|
+
export function decodeMessages(messages) {
|
|
61
|
+
if (!Array.isArray(messages)) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const decoded = [];
|
|
65
|
+
for (const message of messages) {
|
|
66
|
+
if (!isRecord(message)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (message.role === "user") {
|
|
70
|
+
const content = userContent(message.content);
|
|
71
|
+
if (content.length > 0) {
|
|
72
|
+
decoded.push({ role: "user", content });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else if (message.role === "assistant") {
|
|
76
|
+
const content = assistantContent(message.content);
|
|
77
|
+
if (content.length > 0) {
|
|
78
|
+
decoded.push({ role: "assistant", content });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return decoded;
|
|
83
|
+
}
|
|
84
|
+
function userContent(content) {
|
|
85
|
+
if (!Array.isArray(content)) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
const blocks = [];
|
|
89
|
+
for (const block of content) {
|
|
90
|
+
if (!isRecord(block)) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (typeof block.text === "string") {
|
|
94
|
+
blocks.push({ text: block.text });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const decoded = imageBlock(block.image) ??
|
|
98
|
+
documentBlock(block.document) ??
|
|
99
|
+
videoBlock(block.video);
|
|
100
|
+
if (decoded !== null) {
|
|
101
|
+
blocks.push(decoded);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return blocks;
|
|
105
|
+
}
|
|
106
|
+
function assistantContent(content) {
|
|
107
|
+
if (!Array.isArray(content)) {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
const blocks = [];
|
|
111
|
+
for (const block of content) {
|
|
112
|
+
if (isRecord(block) && typeof block.text === "string") {
|
|
113
|
+
blocks.push({ text: block.text });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return blocks;
|
|
117
|
+
}
|
|
118
|
+
function imageBlock(media) {
|
|
119
|
+
if (!isRecord(media)) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const bytes = decodedSourceBytes(media);
|
|
123
|
+
const format = media.format;
|
|
124
|
+
if (bytes === null || typeof format !== "string") {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (!IMAGE_FORMATS.has(format)) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
return { image: { format: format, source: { bytes } } };
|
|
131
|
+
}
|
|
132
|
+
function documentBlock(media) {
|
|
133
|
+
if (!isRecord(media)) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const bytes = decodedSourceBytes(media);
|
|
137
|
+
const { name, format } = media;
|
|
138
|
+
if (bytes === null || typeof format !== "string") {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (!DOCUMENT_FORMATS.has(format)) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
document: { name, format: format, source: { bytes } },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function videoBlock(media) {
|
|
152
|
+
if (!isRecord(media)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const bytes = decodedSourceBytes(media);
|
|
156
|
+
if (bytes === null || typeof media.format !== "string") {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
// The wire carries the Converse token for 3GP; the SDK's is shorter.
|
|
160
|
+
const format = media.format === "three_gp" ? "3gp" : media.format;
|
|
161
|
+
if (!VIDEO_FORMATS.has(format)) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
return { video: { format: format, source: { bytes } } };
|
|
165
|
+
}
|
|
166
|
+
function decodedSourceBytes(media) {
|
|
167
|
+
const source = media.source;
|
|
168
|
+
if (!isRecord(source)) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const bytes = source.bytes;
|
|
172
|
+
if (typeof bytes !== "string" || bytes.length === 0) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
return new Uint8Array(Buffer.from(bytes, "base64"));
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Decode Welt's interrupt answers into Strands' resume input.
|
|
179
|
+
*
|
|
180
|
+
* Welt resumes an interrupted run with a payload mapping each interrupt
|
|
181
|
+
* id to the answer a human chose in the thread. Strands resumes from a
|
|
182
|
+
* list of `interruptResponse` content items; the returned list feeds
|
|
183
|
+
* `Agent.stream()` directly.
|
|
184
|
+
*
|
|
185
|
+
* @param responses - The `interrupt_responses` value of Welt's payload.
|
|
186
|
+
* @returns One `interruptResponse` item per answered interrupt, in
|
|
187
|
+
* payload order.
|
|
188
|
+
*/
|
|
189
|
+
export function decodeInterruptResponses(responses) {
|
|
190
|
+
if (!isRecord(responses)) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const decoded = [];
|
|
194
|
+
for (const [interruptId, response] of Object.entries(responses)) {
|
|
195
|
+
if (typeof response === "string") {
|
|
196
|
+
decoded.push({ interruptResponse: { interruptId, response } });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return decoded;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Build a `file` wire event, which Welt uploads to the Slack thread.
|
|
203
|
+
*
|
|
204
|
+
* `renderableEvents` emits these for the files a tool or the model
|
|
205
|
+
* generates; this builds the same event from arbitrary bytes, for agents
|
|
206
|
+
* that attach files of their own alongside the reduced stream — yield it
|
|
207
|
+
* from the host app.
|
|
208
|
+
*
|
|
209
|
+
* @param name - The upload filename, extension included.
|
|
210
|
+
* @param data - The raw file bytes.
|
|
211
|
+
* @returns The `file` event (name plus base64 bytes).
|
|
212
|
+
* @throws TypeError if the name is empty (Welt drops a nameless file).
|
|
213
|
+
*/
|
|
214
|
+
export function fileEvent(name, data) {
|
|
215
|
+
if (name.length === 0) {
|
|
216
|
+
throw new TypeError("name must not be empty");
|
|
217
|
+
}
|
|
218
|
+
return { file: { name, bytes: Buffer.from(data).toString("base64") } };
|
|
219
|
+
}
|
|
220
|
+
const OPTION_KEYS = new Set(["value", "label", "style"]);
|
|
221
|
+
const INPUT_KEYS = new Set(["label", "multiline"]);
|
|
222
|
+
/**
|
|
223
|
+
* Build an interrupt reason that Welt renders as the specified widgets.
|
|
224
|
+
*
|
|
225
|
+
* Welt renders this shape as `message` followed by one button per option
|
|
226
|
+
* (`options`), a free-text field whose submitted text becomes the
|
|
227
|
+
* interrupt's response (`input`), or both — whichever answer comes
|
|
228
|
+
* first, a pressed button or the submitted text, settles the question.
|
|
229
|
+
* Both widget specs are the wire's own shapes; building them through
|
|
230
|
+
* this helper turns a typo into an immediate TypeError instead of a
|
|
231
|
+
* silent fallback to Welt's default rendering.
|
|
232
|
+
*
|
|
233
|
+
* @param message - The text Welt shows above the widgets.
|
|
234
|
+
* @param options - One entry per button: a required `value` (what the
|
|
235
|
+
* interrupting tool receives as the response when the button is
|
|
236
|
+
* pressed), an optional `label` (the button text; omitted, Welt shows
|
|
237
|
+
* the value), and an optional `style` ("primary" or "danger").
|
|
238
|
+
* @param input - The free-text field: an optional `label` (the field's
|
|
239
|
+
* label) and an optional `multiline` (whether the field accepts
|
|
240
|
+
* multiple lines) — `{}` takes Welt's defaults for both. Omitted, no
|
|
241
|
+
* field renders.
|
|
242
|
+
* @returns The reason to pass to `ToolContext.interrupt`.
|
|
243
|
+
* @throws TypeError if the message is empty, neither options nor input
|
|
244
|
+
* is given, or a widget spec is off — an unknown key, a missing value,
|
|
245
|
+
* an empty or non-string value/label, a style that is not "primary" or
|
|
246
|
+
* "danger", or a non-boolean multiline.
|
|
247
|
+
*/
|
|
248
|
+
export function interruptReason(message, options, input) {
|
|
249
|
+
if (message.length === 0) {
|
|
250
|
+
throw new TypeError("message must not be empty");
|
|
251
|
+
}
|
|
252
|
+
if (options === undefined && input === undefined) {
|
|
253
|
+
throw new TypeError("options or input must be given");
|
|
254
|
+
}
|
|
255
|
+
const reason = { message };
|
|
256
|
+
if (options !== undefined) {
|
|
257
|
+
reason.options = builtOptions(options);
|
|
258
|
+
}
|
|
259
|
+
if (input !== undefined) {
|
|
260
|
+
reason.input = builtInput(input);
|
|
261
|
+
}
|
|
262
|
+
return reason;
|
|
263
|
+
}
|
|
264
|
+
function builtOptions(options) {
|
|
265
|
+
if (options.length === 0) {
|
|
266
|
+
throw new TypeError("options must not be empty");
|
|
267
|
+
}
|
|
268
|
+
const built = [];
|
|
269
|
+
for (const option of options) {
|
|
270
|
+
const unknownKeys = Object.keys(option).filter((key) => !OPTION_KEYS.has(key));
|
|
271
|
+
if (unknownKeys.length > 0) {
|
|
272
|
+
throw new TypeError(`unknown option keys: ${unknownKeys.sort().join(", ")}`);
|
|
273
|
+
}
|
|
274
|
+
const value = option.value;
|
|
275
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
276
|
+
throw new TypeError("option value must be a non-empty string");
|
|
277
|
+
}
|
|
278
|
+
const entry = { value };
|
|
279
|
+
if ("label" in option) {
|
|
280
|
+
const label = option.label;
|
|
281
|
+
if (typeof label !== "string" || label.length === 0) {
|
|
282
|
+
throw new TypeError("option label must be a non-empty string");
|
|
283
|
+
}
|
|
284
|
+
entry.label = label;
|
|
285
|
+
}
|
|
286
|
+
if ("style" in option) {
|
|
287
|
+
const style = option.style;
|
|
288
|
+
if (style !== "primary" && style !== "danger") {
|
|
289
|
+
throw new TypeError(`style must be "primary" or "danger": ${JSON.stringify(style)}`);
|
|
290
|
+
}
|
|
291
|
+
entry.style = style;
|
|
292
|
+
}
|
|
293
|
+
built.push(entry);
|
|
294
|
+
}
|
|
295
|
+
return built;
|
|
296
|
+
}
|
|
297
|
+
function builtInput(input) {
|
|
298
|
+
const unknownKeys = Object.keys(input).filter((key) => !INPUT_KEYS.has(key));
|
|
299
|
+
if (unknownKeys.length > 0) {
|
|
300
|
+
throw new TypeError(`unknown input keys: ${unknownKeys.sort().join(", ")}`);
|
|
301
|
+
}
|
|
302
|
+
const built = {};
|
|
303
|
+
if ("label" in input) {
|
|
304
|
+
const label = input.label;
|
|
305
|
+
if (typeof label !== "string" || label.length === 0) {
|
|
306
|
+
throw new TypeError("input label must be a non-empty string");
|
|
307
|
+
}
|
|
308
|
+
built.label = label;
|
|
309
|
+
}
|
|
310
|
+
if ("multiline" in input) {
|
|
311
|
+
const multiline = input.multiline;
|
|
312
|
+
if (typeof multiline !== "boolean") {
|
|
313
|
+
throw new TypeError("input multiline must be a boolean");
|
|
314
|
+
}
|
|
315
|
+
built.multiline = multiline;
|
|
316
|
+
}
|
|
317
|
+
return built;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Reduce Strands `Agent.stream()` events to the subset Welt renders.
|
|
321
|
+
*
|
|
322
|
+
* Iterates the events of `Agent.stream()` and yields the wire's
|
|
323
|
+
* renderable subset: text chunks (`data`), tool-use indicators
|
|
324
|
+
* (`current_tool_use` / `tool_result`, slimmed so tool output stays off
|
|
325
|
+
* the wire), generated files (`file` — one per image, document, or video
|
|
326
|
+
* block a tool result or the assistant message carries, named after the
|
|
327
|
+
* block's name or kind plus the format as extension), and interrupts
|
|
328
|
+
* (`interrupt` — when the run stops for human input, one per pending
|
|
329
|
+
* interrupt from the stream's final result, its id, name, and reason,
|
|
330
|
+
* the reason passed through unmodified since interpreting a reason is
|
|
331
|
+
* the renderer's job). Everything else is dropped.
|
|
332
|
+
*
|
|
333
|
+
* @param events - The events of `Agent.stream()`.
|
|
334
|
+
* @yields The renderable wire events, in stream order.
|
|
335
|
+
*/
|
|
336
|
+
export async function* renderableEvents(events) {
|
|
337
|
+
for await (const event of events) {
|
|
338
|
+
if (!isRecord(event)) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
switch (event.type) {
|
|
342
|
+
case "modelStreamUpdateEvent": {
|
|
343
|
+
const rendered = modelStreamEvent(event.event);
|
|
344
|
+
if (rendered !== null) {
|
|
345
|
+
yield rendered;
|
|
346
|
+
}
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case "toolResultEvent": {
|
|
350
|
+
yield* toolResultEvents(event.result);
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
case "modelMessageEvent": {
|
|
354
|
+
yield* messageFileEvents(event.message);
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
case "agentResultEvent": {
|
|
358
|
+
yield* interruptEvents(event.result);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
default: {
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function modelStreamEvent(event) {
|
|
368
|
+
if (!isRecord(event)) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
if (event.type === "modelContentBlockDeltaEvent") {
|
|
372
|
+
const delta = event.delta;
|
|
373
|
+
if (isRecord(delta) &&
|
|
374
|
+
delta.type === "textDelta" &&
|
|
375
|
+
typeof delta.text === "string" &&
|
|
376
|
+
delta.text.length > 0) {
|
|
377
|
+
return { data: delta.text };
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
if (event.type === "modelContentBlockStartEvent") {
|
|
382
|
+
const start = event.start;
|
|
383
|
+
if (isRecord(start) && start.type === "toolUseStart") {
|
|
384
|
+
return {
|
|
385
|
+
current_tool_use: {
|
|
386
|
+
toolUseId: stringOrNull(start.toolUseId),
|
|
387
|
+
name: stringOrNull(start.name),
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
function stringOrNull(value) {
|
|
396
|
+
return typeof value === "string" ? value : null;
|
|
397
|
+
}
|
|
398
|
+
function toolResultEvents(result) {
|
|
399
|
+
if (!isRecord(result)) {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
const events = [
|
|
403
|
+
{
|
|
404
|
+
tool_result: {
|
|
405
|
+
toolUseId: stringOrNull(result.toolUseId),
|
|
406
|
+
status: result.status === "error" ? "error" : "success",
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
];
|
|
410
|
+
if (Array.isArray(result.content)) {
|
|
411
|
+
for (const block of result.content) {
|
|
412
|
+
const event = blockFileEvent(block);
|
|
413
|
+
if (event !== null) {
|
|
414
|
+
events.push(event);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return events;
|
|
419
|
+
}
|
|
420
|
+
function messageFileEvents(message) {
|
|
421
|
+
if (!isRecord(message) || !Array.isArray(message.content)) {
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
const events = [];
|
|
425
|
+
for (const block of message.content) {
|
|
426
|
+
const event = blockFileEvent(block);
|
|
427
|
+
if (event !== null) {
|
|
428
|
+
events.push(event);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return events;
|
|
432
|
+
}
|
|
433
|
+
// The stream carries the SDK's block classes, each tagged with a `type`.
|
|
434
|
+
const KIND_BY_BLOCK_TYPE = {
|
|
435
|
+
documentBlock: "document",
|
|
436
|
+
imageBlock: "image",
|
|
437
|
+
videoBlock: "video",
|
|
438
|
+
};
|
|
439
|
+
function blockFileEvent(block) {
|
|
440
|
+
if (!isRecord(block) || typeof block.type !== "string") {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
const kind = KIND_BY_BLOCK_TYPE[block.type];
|
|
444
|
+
if (kind === undefined) {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
const source = block.source;
|
|
448
|
+
if (!isRecord(source) || !(source.bytes instanceof Uint8Array)) {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
return fileEvent(blockFileName(kind, block), source.bytes);
|
|
452
|
+
}
|
|
453
|
+
function blockFileName(kind, block) {
|
|
454
|
+
const name = block.name;
|
|
455
|
+
const base = typeof name === "string" && name.length > 0 ? name : kind;
|
|
456
|
+
const format = block.format;
|
|
457
|
+
if (typeof format !== "string" || format.length === 0) {
|
|
458
|
+
return base;
|
|
459
|
+
}
|
|
460
|
+
return `${base}.${format}`;
|
|
461
|
+
}
|
|
462
|
+
function interruptEvents(result) {
|
|
463
|
+
if (!isRecord(result) || !Array.isArray(result.interrupts)) {
|
|
464
|
+
return [];
|
|
465
|
+
}
|
|
466
|
+
const events = [];
|
|
467
|
+
for (const interrupt of result.interrupts) {
|
|
468
|
+
if (!isRecord(interrupt)) {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
// Welt requires a non-empty id (the resume key) and a string name.
|
|
472
|
+
if (typeof interrupt.id !== "string" || interrupt.id.length === 0) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
events.push({
|
|
476
|
+
interrupt: {
|
|
477
|
+
id: interrupt.id,
|
|
478
|
+
name: typeof interrupt.name === "string" ? interrupt.name : "",
|
|
479
|
+
reason: interrupt.reason,
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return events;
|
|
484
|
+
}
|
|
485
|
+
function isRecord(value) {
|
|
486
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
487
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@welt-io/strands",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "The Strands Agents (TypeScript) adapter for Welt's wire contract.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Takashi Iwamoto",
|
|
7
|
+
"homepage": "https://github.com/iwamot/welt-io-ts",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/iwamot/welt-io-ts.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/iwamot/welt-io-ts/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"welt",
|
|
17
|
+
"strands",
|
|
18
|
+
"slack",
|
|
19
|
+
"agent",
|
|
20
|
+
"adapter"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -p tsconfig.build.json",
|
|
42
|
+
"check": "biome check",
|
|
43
|
+
"check:write": "biome check --write",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"test": "bun test --coverage --coverage-reporter=lcov --coverage-reporter=text"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/bun": "1.3.14",
|
|
49
|
+
"@types/node": "24.13.3",
|
|
50
|
+
"typescript": "7.0.2"
|
|
51
|
+
}
|
|
52
|
+
}
|