@effect-uai/ui 0.0.0-stage → 0.19.0-dev-20261109183959
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 +64 -2
- package/dist/Atoms.d.mts +141 -0
- package/dist/Atoms.d.mts.map +1 -0
- package/dist/Atoms.mjs +132 -0
- package/dist/Atoms.mjs.map +1 -0
- package/dist/Client-C9UYN_VK.d.mts +219 -0
- package/dist/Client-C9UYN_VK.d.mts.map +1 -0
- package/dist/Client.d.mts +2 -0
- package/dist/Client.mjs +55 -0
- package/dist/Client.mjs.map +1 -0
- package/dist/Input-Cz-Kxusm.d.mts +277 -0
- package/dist/Input-Cz-Kxusm.d.mts.map +1 -0
- package/dist/Input.d.mts +2 -0
- package/dist/Input.mjs +127 -0
- package/dist/Input.mjs.map +1 -0
- package/dist/Projection.d.mts +44 -0
- package/dist/Projection.d.mts.map +1 -0
- package/dist/Projection.mjs +133 -0
- package/dist/Projection.mjs.map +1 -0
- package/dist/Run.d.mts +44 -0
- package/dist/Run.d.mts.map +1 -0
- package/dist/Run.mjs +117 -0
- package/dist/Run.mjs.map +1 -0
- package/dist/RunEvent.d.mts +1225 -0
- package/dist/RunEvent.d.mts.map +1 -0
- package/dist/RunEvent.mjs +264 -0
- package/dist/RunEvent.mjs.map +1 -0
- package/dist/RunView-nHfJwN8t.d.mts +380 -0
- package/dist/RunView-nHfJwN8t.d.mts.map +1 -0
- package/dist/RunView.d.mts +2 -0
- package/dist/RunView.mjs +452 -0
- package/dist/RunView.mjs.map +1 -0
- package/dist/SseEncoding.d.mts +437 -0
- package/dist/SseEncoding.d.mts.map +1 -0
- package/dist/SseEncoding.mjs +64 -0
- package/dist/SseEncoding.mjs.map +1 -0
- package/dist/Thread-CAXsjhpX.d.mts +474 -0
- package/dist/Thread-CAXsjhpX.d.mts.map +1 -0
- package/dist/Thread.d.mts +2 -0
- package/dist/Thread.mjs +225 -0
- package/dist/Thread.mjs.map +1 -0
- package/dist/Transport.d.mts +96 -0
- package/dist/Transport.d.mts.map +1 -0
- package/dist/Transport.mjs +82 -0
- package/dist/Transport.mjs.map +1 -0
- package/dist/UiContract.d.mts +113 -0
- package/dist/UiContract.d.mts.map +1 -0
- package/dist/UiContract.mjs +229 -0
- package/dist/UiContract.mjs.map +1 -0
- package/dist/index.d.mts +12 -0
- package/dist/index.mjs +12 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +99 -3
- package/src/Atoms.test.ts +163 -0
- package/src/Atoms.ts +355 -0
- package/src/Client.test.ts +53 -0
- package/src/Client.ts +92 -0
- package/src/Input.test.ts +68 -0
- package/src/Input.ts +165 -0
- package/src/Projection.test.ts +198 -0
- package/src/Projection.ts +142 -0
- package/src/Run.test.ts +119 -0
- package/src/Run.ts +166 -0
- package/src/RunEvent.test.ts +84 -0
- package/src/RunEvent.ts +374 -0
- package/src/RunView.test.ts +98 -0
- package/src/RunView.ts +583 -0
- package/src/SseEncoding.test.ts +46 -0
- package/src/SseEncoding.ts +77 -0
- package/src/Thread.test.ts +304 -0
- package/src/Thread.ts +286 -0
- package/src/Transport.ts +182 -0
- package/src/UiContract.test.ts +342 -0
- package/src/UiContract.ts +402 -0
- package/src/index.ts +11 -0
package/src/Input.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a client sends to start or continue a run. The library only needs an
|
|
3
|
+
* `id` on an input (generated on the client, so optimistic UI can match it
|
|
4
|
+
* to `run-started { inputs }`); this module is a preset, and an app with a
|
|
5
|
+
* different shape passes its own schema.
|
|
6
|
+
*
|
|
7
|
+
* A `submit` carries user `messages` (core message items: the server appends
|
|
8
|
+
* them to its history as they are) and, with `make(DataSchema)`, typed `data` the server
|
|
9
|
+
* interprets (template id, model choice). Decode request bodies with the
|
|
10
|
+
* `*Json` codecs: they carry image bytes over JSON.
|
|
11
|
+
*
|
|
12
|
+
* Constructors build drafts without randomness; the id is assigned where an
|
|
13
|
+
* input is sent (`Atoms.submit`, or `withId`).
|
|
14
|
+
*/
|
|
15
|
+
import * as ToolDecision from "@effect-uai/core/ToolDecision"
|
|
16
|
+
import * as Items from "@effect-uai/core/Items"
|
|
17
|
+
import { Effect, Schema } from "effect"
|
|
18
|
+
import { SecureRandom } from "effect/crypto"
|
|
19
|
+
import { InputId } from "./RunEvent.js"
|
|
20
|
+
|
|
21
|
+
/** A user message: core's `message` item with role `user` and input blocks only. */
|
|
22
|
+
export const UserMessage = Schema.Struct({
|
|
23
|
+
type: Schema.Literal("message"),
|
|
24
|
+
role: Schema.Literal("user"),
|
|
25
|
+
content: Schema.Array(Schema.Union([Items.InputText, Items.InputImage])),
|
|
26
|
+
})
|
|
27
|
+
export type UserMessage = typeof UserMessage.Type
|
|
28
|
+
export type Content = UserMessage["content"][number]
|
|
29
|
+
|
|
30
|
+
/** The answer to one waiting call, with a decision of the app's `ToolDecision.make`. */
|
|
31
|
+
export const answer = <D extends Schema.Top>(decision: D) =>
|
|
32
|
+
Schema.Struct({ callId: Schema.String, decision })
|
|
33
|
+
|
|
34
|
+
/** The default answer: `ToolDecision.Default` (approve, or deny with an optional reason). */
|
|
35
|
+
export const Decision = answer(ToolDecision.Default.schema)
|
|
36
|
+
export type Decision = typeof Decision.Type
|
|
37
|
+
|
|
38
|
+
const submitFields = {
|
|
39
|
+
id: InputId,
|
|
40
|
+
kind: Schema.Literal("submit"),
|
|
41
|
+
messages: Schema.Array(UserMessage),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const resolveFields = <D extends Schema.Top>(decision: D) => ({
|
|
45
|
+
id: InputId,
|
|
46
|
+
kind: Schema.Literal("resolve"),
|
|
47
|
+
decisions: Schema.Array(answer(decision)),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export const Submit = Schema.Struct(submitFields)
|
|
51
|
+
export type Submit = typeof Submit.Type
|
|
52
|
+
|
|
53
|
+
export const Resolve = Schema.Struct(resolveFields(ToolDecision.Default.schema))
|
|
54
|
+
export type Resolve = typeof Resolve.Type
|
|
55
|
+
|
|
56
|
+
export const Input = Schema.Union([Submit, Resolve])
|
|
57
|
+
export type Input = typeof Input.Type
|
|
58
|
+
|
|
59
|
+
export const SubmitJson = Schema.toCodecJson(Submit)
|
|
60
|
+
export const InputJson = Schema.toCodecJson(Input)
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The preset with typed app `data` (required on `submit`, optional on
|
|
64
|
+
* `resolve`) and, optionally, the app's decisions (`ToolDecision.make(...).schema`).
|
|
65
|
+
*/
|
|
66
|
+
export function make<D extends Schema.Top>(data: D): Preset<D, typeof ToolDecision.Default.schema>
|
|
67
|
+
export function make<D extends Schema.Top, A extends Schema.Top>(
|
|
68
|
+
data: D,
|
|
69
|
+
decisions: A,
|
|
70
|
+
): Preset<D, A>
|
|
71
|
+
export function make(data: Schema.Top, decisions: Schema.Top = ToolDecision.Default.schema) {
|
|
72
|
+
return preset(data, decisions)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type Preset<D extends Schema.Top, A extends Schema.Top> = ReturnType<typeof preset<D, A>>
|
|
76
|
+
|
|
77
|
+
const preset = <D extends Schema.Top, A extends Schema.Top>(data: D, decisions: A) => {
|
|
78
|
+
const Submit = Schema.Struct({ ...submitFields, data })
|
|
79
|
+
const Resolve = Schema.Struct({ ...resolveFields(decisions), data: Schema.optionalKey(data) })
|
|
80
|
+
const Input = Schema.Union([Submit, Resolve])
|
|
81
|
+
return {
|
|
82
|
+
Submit,
|
|
83
|
+
Resolve,
|
|
84
|
+
Input,
|
|
85
|
+
SubmitJson: Schema.toCodecJson(Submit),
|
|
86
|
+
InputJson: Schema.toCodecJson(Input),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** An input whose `id` is not assigned yet (per union member). */
|
|
91
|
+
export type Draft<I> = I extends unknown ? Omit<I, "id"> & { readonly id?: InputId } : never
|
|
92
|
+
|
|
93
|
+
/** A draft with its `id` assigned. */
|
|
94
|
+
export type Identified<D> = D & { readonly id: InputId }
|
|
95
|
+
|
|
96
|
+
/** What a transport receives for inputs of type `I`: the draft `submit` took, with its id. */
|
|
97
|
+
export type Sent<I = Input> = Identified<Draft<I>>
|
|
98
|
+
|
|
99
|
+
/** Assign a UUIDv7 id unless the draft already has one. */
|
|
100
|
+
export const withId = <D extends { readonly id?: InputId }>(
|
|
101
|
+
draft: D,
|
|
102
|
+
): Effect.Effect<Identified<D>, never, SecureRandom.SecureRandom> => {
|
|
103
|
+
const { id } = draft
|
|
104
|
+
return id !== undefined
|
|
105
|
+
? Effect.succeed({ ...draft, id })
|
|
106
|
+
: SecureRandom.SecureRandom.use((random) => random.uuidV7()).pipe(
|
|
107
|
+
Effect.map((uuid) => ({ ...draft, id: InputId.make(uuid) })),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A user message: `text` first, then any attachments (images). */
|
|
112
|
+
export const user = (text: string, attachments: ReadonlyArray<Content> = []): UserMessage => ({
|
|
113
|
+
type: "message",
|
|
114
|
+
role: "user",
|
|
115
|
+
content: [{ type: "input_text", text }, ...attachments],
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
type Id = { readonly id?: InputId }
|
|
119
|
+
|
|
120
|
+
const toMessages = (messages: string | ReadonlyArray<UserMessage>): ReadonlyArray<UserMessage> =>
|
|
121
|
+
typeof messages === "string" ? [user(messages)] : messages
|
|
122
|
+
|
|
123
|
+
export function submit(messages: string | ReadonlyArray<UserMessage>, options?: Id): Draft<Submit>
|
|
124
|
+
export function submit<D>(
|
|
125
|
+
messages: string | ReadonlyArray<UserMessage>,
|
|
126
|
+
options: Id & { readonly data: D },
|
|
127
|
+
): Draft<Submit> & { readonly data: D }
|
|
128
|
+
export function submit(
|
|
129
|
+
messages: string | ReadonlyArray<UserMessage>,
|
|
130
|
+
options?: Id & { readonly data?: unknown },
|
|
131
|
+
): Draft<Submit> & { readonly data?: unknown } {
|
|
132
|
+
return {
|
|
133
|
+
...options,
|
|
134
|
+
kind: "submit",
|
|
135
|
+
messages: toMessages(messages),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A resolve draft; the decision type defaults to `ToolDecision.Default`'s. */
|
|
140
|
+
export type ResolveDraft<A = Decision["decision"]> = {
|
|
141
|
+
readonly id?: InputId
|
|
142
|
+
readonly kind: "resolve"
|
|
143
|
+
readonly decisions: ReadonlyArray<ToolDecision.Answer<A>>
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function resolve<A = Decision["decision"]>(
|
|
147
|
+
decisions: ReadonlyArray<ToolDecision.Answer<A>>,
|
|
148
|
+
options?: Id,
|
|
149
|
+
): ResolveDraft<A>
|
|
150
|
+
export function resolve<A, D>(
|
|
151
|
+
decisions: ReadonlyArray<ToolDecision.Answer<A>>,
|
|
152
|
+
options: Id & { readonly data: D },
|
|
153
|
+
): ResolveDraft<A> & { readonly data: D }
|
|
154
|
+
export function resolve(
|
|
155
|
+
decisions: ReadonlyArray<ToolDecision.Answer<unknown>>,
|
|
156
|
+
options?: Id & { readonly data?: unknown },
|
|
157
|
+
): ResolveDraft<unknown> & { readonly data?: unknown } {
|
|
158
|
+
return { ...options, kind: "resolve", decisions }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** All text of the submit's messages, joined by blank lines. */
|
|
162
|
+
export const text = (input: Pick<Submit, "messages">): string =>
|
|
163
|
+
input.messages
|
|
164
|
+
.flatMap((m) => m.content.flatMap((block) => (block.type === "input_text" ? [block.text] : [])))
|
|
165
|
+
.join("\n\n")
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type * as Items from "@effect-uai/core/Items"
|
|
2
|
+
import { loop, onTurnComplete, stop } from "@effect-uai/core/Loop"
|
|
3
|
+
import * as MockProvider from "@effect-uai/core/testing/MockProvider"
|
|
4
|
+
import * as Tool from "@effect-uai/core/Tool"
|
|
5
|
+
import * as Toolkit from "@effect-uai/core/Toolkit"
|
|
6
|
+
import * as ToolOutput from "@effect-uai/core/ToolOutput"
|
|
7
|
+
import * as Turn from "@effect-uai/core/Turn"
|
|
8
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
9
|
+
import { Effect, pipe, Result, Schema, Stream } from "effect"
|
|
10
|
+
import * as Projection from "./Projection.js"
|
|
11
|
+
import type { RunEvent } from "./RunEvent.js"
|
|
12
|
+
import * as UiContract from "./UiContract.js"
|
|
13
|
+
|
|
14
|
+
class CityNotFound extends Schema.TaggedError<CityNotFound>()("CityNotFound", {
|
|
15
|
+
city: Schema.String,
|
|
16
|
+
}) {}
|
|
17
|
+
|
|
18
|
+
const getWeather = Tool.make({
|
|
19
|
+
name: "get_weather",
|
|
20
|
+
description: "Current weather for a city.",
|
|
21
|
+
inputSchema: Schema.Struct({ city: Schema.String }),
|
|
22
|
+
outputSchema: Schema.Struct({ city: Schema.String, observedAt: Schema.DateFromString }),
|
|
23
|
+
failureSchema: CityNotFound,
|
|
24
|
+
run: ({ city }) =>
|
|
25
|
+
city === "Atlantis"
|
|
26
|
+
? Effect.fail(new CityNotFound({ city }))
|
|
27
|
+
: Effect.succeed({ city, observedAt: new Date("2026-01-01T12:00:00.000Z") }),
|
|
28
|
+
})
|
|
29
|
+
const search = Tool.make({
|
|
30
|
+
name: "search",
|
|
31
|
+
description: "Search.",
|
|
32
|
+
inputSchema: Tool.noInput,
|
|
33
|
+
outputSchema: Schema.Struct({ hits: Schema.Number }),
|
|
34
|
+
run: () => Stream.make({ hits: 1 }, { hits: 3 }),
|
|
35
|
+
})
|
|
36
|
+
const toolkit = Toolkit.make(getWeather, search)
|
|
37
|
+
const contract = UiContract.make({ tools: [getWeather, search] })
|
|
38
|
+
|
|
39
|
+
const usage = { input_tokens: 5, output_tokens: 5 }
|
|
40
|
+
const callTurn = (calls: ReadonlyArray<readonly [string, string, string]>): Turn.Turn => ({
|
|
41
|
+
stop_reason: "tool_calls",
|
|
42
|
+
usage,
|
|
43
|
+
items: calls.map(([call_id, name, args]) => ({
|
|
44
|
+
type: "function_call",
|
|
45
|
+
call_id,
|
|
46
|
+
name,
|
|
47
|
+
arguments: args,
|
|
48
|
+
})),
|
|
49
|
+
})
|
|
50
|
+
const answerTurn: Turn.Turn = {
|
|
51
|
+
stop_reason: "stop",
|
|
52
|
+
usage,
|
|
53
|
+
items: [
|
|
54
|
+
{ type: "reasoning", summary: "Weather tool says 19.4." },
|
|
55
|
+
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Done." }] },
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type State = { readonly history: ReadonlyArray<Items.HistoryItem> }
|
|
60
|
+
|
|
61
|
+
const runLoop = (turns: ReadonlyArray<Turn.Turn>) => {
|
|
62
|
+
const { service } = MockProvider.make(turns)
|
|
63
|
+
const initialState: State = { history: [] }
|
|
64
|
+
return pipe(
|
|
65
|
+
initialState,
|
|
66
|
+
loop((state: State) =>
|
|
67
|
+
service.streamTurn({ history: state.history, model: "mock", tools: toolkit }).pipe(
|
|
68
|
+
onTurnComplete((turn) =>
|
|
69
|
+
Effect.sync(() => {
|
|
70
|
+
const calls = Turn.getToolCalls(turn)
|
|
71
|
+
return calls.length === 0
|
|
72
|
+
? stop()
|
|
73
|
+
: Toolkit.run(toolkit, calls, { concurrency: 1 }).pipe(
|
|
74
|
+
Toolkit.next(toolkit, state, turn),
|
|
75
|
+
)
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
Projection.toRunEvents({ contract }),
|
|
81
|
+
Stream.runCollect,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("Projection.toRunEvents", () => {
|
|
86
|
+
it.effect("projects a tool loop: steps per turn, tool lifecycle, encoded outcomes", () =>
|
|
87
|
+
Effect.gen(function* () {
|
|
88
|
+
const events = yield* runLoop([
|
|
89
|
+
callTurn([
|
|
90
|
+
["c1", "get_weather", `{"city":"Lisbon"}`],
|
|
91
|
+
["c2", "get_weather", `{"city":"Atlantis"}`],
|
|
92
|
+
]),
|
|
93
|
+
answerTurn,
|
|
94
|
+
])
|
|
95
|
+
expect(events.filter((e) => e.type === "tool-result")).toEqual<ReadonlyArray<RunEvent>>([
|
|
96
|
+
{
|
|
97
|
+
type: "tool-result",
|
|
98
|
+
callId: "c1",
|
|
99
|
+
name: "get_weather",
|
|
100
|
+
result: {
|
|
101
|
+
_tag: "Success",
|
|
102
|
+
value: { city: "Lisbon", observedAt: "2026-01-01T12:00:00.000Z" },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
type: "tool-result",
|
|
107
|
+
callId: "c2",
|
|
108
|
+
name: "get_weather",
|
|
109
|
+
result: { _tag: "Failure", error: { _tag: "CityNotFound", city: "Atlantis" } },
|
|
110
|
+
},
|
|
111
|
+
])
|
|
112
|
+
expect(events.map((e) => e.type)).toEqual([
|
|
113
|
+
"step-started",
|
|
114
|
+
"tool-call-started",
|
|
115
|
+
"tool-call-started",
|
|
116
|
+
"tool-call-ready",
|
|
117
|
+
"tool-call-ready",
|
|
118
|
+
"step-finished",
|
|
119
|
+
"tool-result",
|
|
120
|
+
"tool-result",
|
|
121
|
+
"step-started",
|
|
122
|
+
"reasoning-started",
|
|
123
|
+
"text-delta",
|
|
124
|
+
"step-finished",
|
|
125
|
+
])
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
it.effect("projects a streaming tool's values as partials before its result", () =>
|
|
130
|
+
Effect.gen(function* () {
|
|
131
|
+
const events = yield* runLoop([callTurn([["s1", "search", "{}"]]), answerTurn])
|
|
132
|
+
expect(events.filter((e) => e.type === "tool-partial" || e.type === "tool-result")).toEqual<
|
|
133
|
+
ReadonlyArray<RunEvent>
|
|
134
|
+
>([
|
|
135
|
+
{ type: "tool-partial", callId: "s1", name: "search", value: { hits: 1 } },
|
|
136
|
+
{ type: "tool-partial", callId: "s1", name: "search", value: { hits: 3 } },
|
|
137
|
+
{
|
|
138
|
+
type: "tool-result",
|
|
139
|
+
callId: "s1",
|
|
140
|
+
name: "search",
|
|
141
|
+
result: { _tag: "Success", value: { hits: 3 } },
|
|
142
|
+
},
|
|
143
|
+
])
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
it.effect("projects a history item an app closed itself from its parts", () =>
|
|
148
|
+
Effect.gen(function* () {
|
|
149
|
+
const cancelled = yield* ToolOutput.failure(
|
|
150
|
+
{ call_id: "c1" },
|
|
151
|
+
ToolOutput.CoreError,
|
|
152
|
+
new ToolOutput.Cancelled({ message: "user moved on" }),
|
|
153
|
+
)
|
|
154
|
+
const events = yield* Stream.fromIterable<Projection.Source>([
|
|
155
|
+
Turn.TurnEvent.TurnComplete({ turn: callTurn([["c1", "get_weather", "{}"]]) }),
|
|
156
|
+
cancelled,
|
|
157
|
+
]).pipe(Projection.toRunEvents({ contract }), Stream.runCollect)
|
|
158
|
+
expect(events.at(-1)).toEqual<RunEvent>({
|
|
159
|
+
type: "tool-result",
|
|
160
|
+
callId: "c1",
|
|
161
|
+
name: "get_weather",
|
|
162
|
+
result: { _tag: "Failure", error: { _tag: "Cancelled", message: "user moved on" } },
|
|
163
|
+
})
|
|
164
|
+
}),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
it.effect("fails the stream when a value doesn't fit its contract schema", () =>
|
|
168
|
+
Effect.gen(function* () {
|
|
169
|
+
const output: Projection.Source = {
|
|
170
|
+
_tag: "Output",
|
|
171
|
+
call_id: "c1",
|
|
172
|
+
tool: "search",
|
|
173
|
+
result: Result.succeed({ hits: "many" }),
|
|
174
|
+
}
|
|
175
|
+
const error = yield* Effect.flip(
|
|
176
|
+
Stream.make(output).pipe(Projection.toRunEvents({ contract }), Stream.runCollect),
|
|
177
|
+
)
|
|
178
|
+
expect(error._tag).toBe("SchemaError")
|
|
179
|
+
}),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
it.effect("projects a refusal as refusal-delta, not as text", () =>
|
|
183
|
+
Stream.make(
|
|
184
|
+
Turn.TurnEvent.RefusalDelta({ text: "I can't help with that." }),
|
|
185
|
+
Turn.TurnEvent.TurnComplete({ turn: { stop_reason: "refusal", usage, items: [] } }),
|
|
186
|
+
).pipe(
|
|
187
|
+
Projection.toRunEvents({ contract }),
|
|
188
|
+
Stream.runCollect,
|
|
189
|
+
Effect.map((events) =>
|
|
190
|
+
expect(events).toEqual<ReadonlyArray<RunEvent>>([
|
|
191
|
+
{ type: "step-started" },
|
|
192
|
+
{ type: "refusal-delta", text: "I can't help with that." },
|
|
193
|
+
{ type: "step-finished", usage },
|
|
194
|
+
]),
|
|
195
|
+
),
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project an effect-uai loop onto `RunEvent`s. The projection is a fold pair
|
|
3
|
+
* (`initial`, `step`) because step boundaries need state; `toRunEvents` runs
|
|
4
|
+
* it over a stream. To add app events (metrics, domain data), map them
|
|
5
|
+
* yourself and delegate the rest to `step`.
|
|
6
|
+
*
|
|
7
|
+
* Never forwards `TurnComplete.turn` itself: it carries `providerData`
|
|
8
|
+
* (encrypted reasoning, item ids) that must not reach a browser.
|
|
9
|
+
*/
|
|
10
|
+
import type * as Items from "@effect-uai/core/Items"
|
|
11
|
+
import type * as ToolEvent from "@effect-uai/core/ToolEvent"
|
|
12
|
+
import * as Turn from "@effect-uai/core/Turn"
|
|
13
|
+
import { Effect, Match, Option, Schema, Stream } from "effect"
|
|
14
|
+
import type { Json, RunEvent } from "./RunEvent.js"
|
|
15
|
+
import * as UiContract from "./UiContract.js"
|
|
16
|
+
|
|
17
|
+
/** What an effect-uai loop emits: turn events, tool events, and plain tool outputs. */
|
|
18
|
+
export type Source = Turn.TurnEvent | ToolEvent.ToolEvent | Items.ToolCallOutput
|
|
19
|
+
|
|
20
|
+
export const Options = Schema.Struct({
|
|
21
|
+
/**
|
|
22
|
+
* `"signal"` (default): only `reasoning-started`. `"summary"`: also
|
|
23
|
+
* forward summary deltas. `"full"`: also forward raw reasoning traces.
|
|
24
|
+
*/
|
|
25
|
+
reasoning: Schema.optionalKey(Schema.Literals(["signal", "summary", "full"])),
|
|
26
|
+
/** Forward raw tool-argument deltas (`tool-call-args-delta`). Default false. */
|
|
27
|
+
toolArgs: Schema.optionalKey(Schema.Boolean),
|
|
28
|
+
})
|
|
29
|
+
export type Options = typeof Options.Type
|
|
30
|
+
|
|
31
|
+
/** `Options` plus the contract whose tool schemas encode tool values and errors. */
|
|
32
|
+
export type Config = Options & {
|
|
33
|
+
readonly contract: Pick<UiContract.UiContract<any>, "toolEvent" | "toolOutput">
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const State = Schema.Struct({
|
|
37
|
+
inStep: Schema.Boolean,
|
|
38
|
+
reasoningShown: Schema.Boolean,
|
|
39
|
+
/** Tool names by call id, for outputs that arrive as plain `function_call_output` items. */
|
|
40
|
+
tools: Schema.Record(Schema.String, Schema.String),
|
|
41
|
+
})
|
|
42
|
+
export type State = typeof State.Type
|
|
43
|
+
|
|
44
|
+
export const initial: State = { inStep: false, reasoningShown: false, tools: {} }
|
|
45
|
+
|
|
46
|
+
type Step = readonly [State, ReadonlyArray<RunEvent>]
|
|
47
|
+
|
|
48
|
+
// Tool arguments are a JSON string; hand the client the decoded value, falling
|
|
49
|
+
// back to the raw string when it isn't JSON.
|
|
50
|
+
const parseJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json))
|
|
51
|
+
const decodeJson = (raw: string): Json => Option.getOrElse(parseJson(raw), () => raw)
|
|
52
|
+
|
|
53
|
+
// Content belongs to a step (one model turn); open one lazily on its first event.
|
|
54
|
+
const inStep = (s: State, events: ReadonlyArray<RunEvent>): Step =>
|
|
55
|
+
s.inStep
|
|
56
|
+
? [s, events]
|
|
57
|
+
: [{ ...s, inStep: true, reasoningShown: false }, [{ type: "step-started" }, ...events]]
|
|
58
|
+
|
|
59
|
+
const reasoning =
|
|
60
|
+
(options: Options | undefined) =>
|
|
61
|
+
(s: State, e: Extract<Turn.TurnEvent, { _tag: "ReasoningDelta" }>): Step => {
|
|
62
|
+
const mode = options?.reasoning ?? "signal"
|
|
63
|
+
const forward = mode === "full" || (mode === "summary" && e.kind === "summary")
|
|
64
|
+
const [opened, prefix] = inStep(s, [])
|
|
65
|
+
const started: ReadonlyArray<RunEvent> = opened.reasoningShown
|
|
66
|
+
? []
|
|
67
|
+
: [{ type: "reasoning-started" }]
|
|
68
|
+
const delta: ReadonlyArray<RunEvent> = forward
|
|
69
|
+
? [{ type: "reasoning-delta", text: e.text, kind: e.kind }]
|
|
70
|
+
: []
|
|
71
|
+
return [{ ...opened, reasoningShown: true }, [...prefix, ...started, ...delta]]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const turnComplete = (s: State, turn: Turn.Turn): Step => {
|
|
75
|
+
const calls = Turn.getToolCalls(turn)
|
|
76
|
+
const ready = calls.map((call): RunEvent => ({
|
|
77
|
+
type: "tool-call-ready",
|
|
78
|
+
callId: call.call_id,
|
|
79
|
+
name: call.name,
|
|
80
|
+
input: decodeJson(call.arguments),
|
|
81
|
+
}))
|
|
82
|
+
const [, events] = inStep(s, [...ready, { type: "step-finished", usage: turn.usage }])
|
|
83
|
+
const tools = { ...s.tools, ...Object.fromEntries(calls.map((c) => [c.call_id, c.name])) }
|
|
84
|
+
return [{ ...initial, tools }, events]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A history item already holds the encoded value; the contract shapes it for the wire.
|
|
88
|
+
const outputResult = (config: Config, s: State, o: Items.ToolCallOutput) =>
|
|
89
|
+
Effect.map(config.contract.toolOutput(o, s.tools[o.call_id] ?? ""), (event): Step => [s, [event]])
|
|
90
|
+
|
|
91
|
+
/** Map one loop event onto zero or more `RunEvent`s; tool values are encoded by the contract. */
|
|
92
|
+
export const step =
|
|
93
|
+
(config: Config) =>
|
|
94
|
+
(s: State, event: Source): Effect.Effect<Step, Schema.SchemaError> =>
|
|
95
|
+
Match.value(event).pipe(
|
|
96
|
+
Match.when({ type: "function_call_output" }, (o) => outputResult(config, s, o)),
|
|
97
|
+
Match.tags({
|
|
98
|
+
Partial: (e) => Effect.map(config.contract.toolEvent(e), (r): Step => [s, [r]]),
|
|
99
|
+
Output: (e) => Effect.map(config.contract.toolEvent(e), (r): Step => [s, [r]]),
|
|
100
|
+
}),
|
|
101
|
+
Match.orElse((e) => Effect.succeed(turnStep(config)(s, e))),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const turnStep =
|
|
105
|
+
(options: Options) =>
|
|
106
|
+
(s: State, event: Turn.TurnEvent): Step =>
|
|
107
|
+
Match.value(event).pipe(
|
|
108
|
+
Match.tagsExhaustive({
|
|
109
|
+
TextDelta: (e) => inStep(s, [{ type: "text-delta", text: e.text }]),
|
|
110
|
+
RefusalDelta: (e) => inStep(s, [{ type: "refusal-delta", text: e.text }]),
|
|
111
|
+
ReasoningDelta: (e) => reasoning(options)(s, e),
|
|
112
|
+
ToolCallStart: (e) =>
|
|
113
|
+
inStep(s, [{ type: "tool-call-started", callId: e.call_id, name: e.name }]),
|
|
114
|
+
ToolCallArgsDelta: (e) =>
|
|
115
|
+
inStep(
|
|
116
|
+
s,
|
|
117
|
+
options?.toolArgs === true
|
|
118
|
+
? [{ type: "tool-call-args-delta", callId: e.call_id, delta: e.delta }]
|
|
119
|
+
: [],
|
|
120
|
+
),
|
|
121
|
+
CitationAdded: (e) => inStep(s, [{ type: "citation", annotation: e.annotation }]),
|
|
122
|
+
ImageOutput: (e) =>
|
|
123
|
+
inStep(s, [
|
|
124
|
+
e.partialIndex === undefined
|
|
125
|
+
? { type: "image", image: e.image }
|
|
126
|
+
: { type: "image", image: e.image, partialIndex: e.partialIndex },
|
|
127
|
+
]),
|
|
128
|
+
// Usage arrives on `step-finished`; search progress has no UI event yet.
|
|
129
|
+
UsageUpdate: (): Step => [s, []],
|
|
130
|
+
WebSearchCall: (): Step => [s, []],
|
|
131
|
+
TurnComplete: (e) => turnComplete(s, e.turn),
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Stream operator: project a loop's events onto `RunEvent`s. A tool value the
|
|
137
|
+
* contract can't encode fails the stream: a contract violation, not data to show.
|
|
138
|
+
*/
|
|
139
|
+
export const toRunEvents =
|
|
140
|
+
(config: Config) =>
|
|
141
|
+
<E, R>(self: Stream.Stream<Source, E, R>): Stream.Stream<RunEvent, E | Schema.SchemaError, R> =>
|
|
142
|
+
self.pipe(Stream.mapAccumEffect(() => initial, step(config)))
|
package/src/Run.test.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as AiError from "@effect-uai/core/AiError"
|
|
2
|
+
import { describe, it } from "@effect/vitest"
|
|
3
|
+
import { Deferred, Effect, Fiber, Stream } from "effect"
|
|
4
|
+
import { expect } from "vitest"
|
|
5
|
+
import * as Run from "./Run.js"
|
|
6
|
+
import { InputId, RunId, type RunEvent, StreamId } from "./RunEvent.js"
|
|
7
|
+
|
|
8
|
+
const r1 = RunId.make("r1")
|
|
9
|
+
const text = (t: string): RunEvent => ({ type: "text-delta", text: t })
|
|
10
|
+
const types = (events: ReadonlyArray<RunEvent>) => events.map((e) => e.type)
|
|
11
|
+
|
|
12
|
+
describe("Run frame", () => {
|
|
13
|
+
const frame = <E>(body: Stream.Stream<RunEvent, E>) =>
|
|
14
|
+
body.pipe(
|
|
15
|
+
Stream.catchCause((cause) => Stream.succeed(Run.failed(cause))),
|
|
16
|
+
Run.start({ runId: r1, inputs: [InputId.make("in_1")] }),
|
|
17
|
+
Run.complete,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
it.effect("brackets a successful body with run-started and run-completed", () =>
|
|
21
|
+
frame(Stream.make(text("hi"))).pipe(
|
|
22
|
+
Stream.runCollect,
|
|
23
|
+
Effect.map((events) => {
|
|
24
|
+
expect(types(events)).toEqual(["run-started", "text-delta", "run-completed"])
|
|
25
|
+
expect(events[0]).toMatchObject({ runId: "r1", inputs: ["in_1"] })
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
it.effect("turns a failure into run-failed with the error's tag", () =>
|
|
31
|
+
frame(
|
|
32
|
+
Stream.make(text("partial")).pipe(
|
|
33
|
+
Stream.concat(Stream.fail(new AiError.RateLimited({ provider: "x", raw: null }))),
|
|
34
|
+
),
|
|
35
|
+
).pipe(
|
|
36
|
+
Stream.runCollect,
|
|
37
|
+
Effect.map((events) => {
|
|
38
|
+
expect(types(events)).toEqual(["run-started", "text-delta", "run-failed"])
|
|
39
|
+
expect(events.at(-1)).toEqual({ type: "run-failed", reason: "RateLimited" })
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
it.effect("lets the body's own terminal win and end the run", () =>
|
|
45
|
+
frame(
|
|
46
|
+
Stream.fromIterable<RunEvent>([
|
|
47
|
+
text("needs approval"),
|
|
48
|
+
{ type: "run-interrupted", interrupts: [] },
|
|
49
|
+
text("never sent"),
|
|
50
|
+
]),
|
|
51
|
+
).pipe(
|
|
52
|
+
Stream.runCollect,
|
|
53
|
+
Effect.map((events) =>
|
|
54
|
+
expect(types(events)).toEqual(["run-started", "text-delta", "run-interrupted"]),
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
it.effect("ends with run-stopped when the stop signal completes", () =>
|
|
60
|
+
Effect.gen(function* () {
|
|
61
|
+
const stop = yield* Deferred.make<void>()
|
|
62
|
+
const fiber = yield* Stream.make(text("streaming")).pipe(
|
|
63
|
+
Stream.concat(Stream.never),
|
|
64
|
+
Run.stopWhen(Deferred.await(stop)),
|
|
65
|
+
frame,
|
|
66
|
+
Stream.runCollect,
|
|
67
|
+
Effect.forkChild,
|
|
68
|
+
)
|
|
69
|
+
yield* Deferred.succeed(stop, undefined)
|
|
70
|
+
const events = yield* Fiber.join(fiber)
|
|
71
|
+
expect(types(events)).toEqual(["run-started", "text-delta", "run-stopped"])
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe("Run.sequence", () => {
|
|
77
|
+
it.effect("numbers frames per stream and tags run-scoped ones with the open run", () =>
|
|
78
|
+
Stream.fromIterable<RunEvent>([
|
|
79
|
+
{ type: "data", name: "before", value: 1 },
|
|
80
|
+
{ type: "run-started", runId: r1, inputs: [], protocol: 2 },
|
|
81
|
+
text("hi"),
|
|
82
|
+
{ type: "run-completed" },
|
|
83
|
+
{ type: "live", name: "after", value: 2 },
|
|
84
|
+
]).pipe(
|
|
85
|
+
Run.sequence(StreamId.make("st_1"), { from: 10 }),
|
|
86
|
+
Stream.runCollect,
|
|
87
|
+
Effect.map((frames) => {
|
|
88
|
+
expect(frames.map((f) => f.seq)).toEqual([11, 12, 13, 14, 15])
|
|
89
|
+
expect(frames.map((f) => f.runId)).toEqual([undefined, "r1", "r1", "r1", undefined])
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("Run.coalesce", () => {
|
|
96
|
+
it.effect("merges adjacent deltas of the same kind and keeps everything else in order", () =>
|
|
97
|
+
Stream.fromIterable<RunEvent>([
|
|
98
|
+
text("Hel"),
|
|
99
|
+
text("lo"),
|
|
100
|
+
{ type: "tool-call-args-delta", callId: "c1", delta: `{"a"` },
|
|
101
|
+
{ type: "tool-call-args-delta", callId: "c1", delta: ":1}" },
|
|
102
|
+
{ type: "tool-call-args-delta", callId: "c2", delta: "{}" },
|
|
103
|
+
{ type: "step-finished" },
|
|
104
|
+
text("!"),
|
|
105
|
+
]).pipe(
|
|
106
|
+
Run.coalesce("50 millis"),
|
|
107
|
+
Stream.runCollect,
|
|
108
|
+
Effect.map((events) =>
|
|
109
|
+
expect(events).toEqual<ReadonlyArray<RunEvent>>([
|
|
110
|
+
text("Hello"),
|
|
111
|
+
{ type: "tool-call-args-delta", callId: "c1", delta: `{"a":1}` },
|
|
112
|
+
{ type: "tool-call-args-delta", callId: "c2", delta: "{}" },
|
|
113
|
+
{ type: "step-finished" },
|
|
114
|
+
text("!"),
|
|
115
|
+
]),
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
})
|