@effect-uai/ui 0.0.0-stage → 0.17.0
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 +114 -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/Run.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side stream operators for runs. Each does one thing; a run pipes
|
|
3
|
+
* the ones it needs:
|
|
4
|
+
*
|
|
5
|
+
* agent(history).pipe(
|
|
6
|
+
* Projection.toRunEvents({ contract }),
|
|
7
|
+
* Run.coalesce("30 millis"),
|
|
8
|
+
* Stream.catchCause((cause) => Stream.succeed(Run.failed(cause))),
|
|
9
|
+
* Run.stopWhen(Deferred.await(stop)), // durable runs
|
|
10
|
+
* Run.start({ runId, inputs }),
|
|
11
|
+
* Run.complete,
|
|
12
|
+
* Run.sequence(streamId),
|
|
13
|
+
* )
|
|
14
|
+
*
|
|
15
|
+
* In this order they keep the protocol's rule: `run-started` first, exactly
|
|
16
|
+
* one terminal last. Direct mode needs no stop: when the client goes away,
|
|
17
|
+
* the server interrupts the stream (Effect's `HttpServer`, or
|
|
18
|
+
* `HttpRouter.toWebHandler` behind a framework's route).
|
|
19
|
+
*/
|
|
20
|
+
import { Array as Arr, Cause, Effect, Predicate, Schema, Stream } from "effect"
|
|
21
|
+
import type * as Duration from "effect/Duration"
|
|
22
|
+
import {
|
|
23
|
+
type Envelope,
|
|
24
|
+
type InputId,
|
|
25
|
+
isTerminal,
|
|
26
|
+
PROTOCOL_VERSION,
|
|
27
|
+
RunId,
|
|
28
|
+
type RunEvent,
|
|
29
|
+
type StreamId,
|
|
30
|
+
} from "./RunEvent.js"
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// A run's frame: start, stop, failure, completion
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export type StartOptions = {
|
|
37
|
+
readonly runId: RunId
|
|
38
|
+
/** Client input ids the run answers. */
|
|
39
|
+
readonly inputs?: ReadonlyArray<InputId>
|
|
40
|
+
/** Role label when a stream carries several kinds of run. */
|
|
41
|
+
readonly name?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Prepend `run-started`. */
|
|
45
|
+
export const start =
|
|
46
|
+
(options: StartOptions) =>
|
|
47
|
+
<E, R>(self: Stream.Stream<RunEvent, E, R>): Stream.Stream<RunEvent, E, R> => {
|
|
48
|
+
const started: RunEvent = {
|
|
49
|
+
type: "run-started",
|
|
50
|
+
runId: options.runId,
|
|
51
|
+
inputs: options.inputs ?? [],
|
|
52
|
+
protocol: PROTOCOL_VERSION,
|
|
53
|
+
...(options.name !== undefined ? { name: options.name } : {}),
|
|
54
|
+
}
|
|
55
|
+
return Stream.make(started).pipe(Stream.concat(self))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** End the run at its first terminal; append `run-completed` if it ends without one. */
|
|
59
|
+
export const complete = <E, R>(
|
|
60
|
+
self: Stream.Stream<RunEvent, E, R>,
|
|
61
|
+
): Stream.Stream<RunEvent, E, R> =>
|
|
62
|
+
self.pipe(
|
|
63
|
+
Stream.takeUntil(isTerminal),
|
|
64
|
+
Stream.mapAccum(
|
|
65
|
+
() => false,
|
|
66
|
+
(ended, event): readonly [boolean, ReadonlyArray<RunEvent>] => [
|
|
67
|
+
ended || isTerminal(event),
|
|
68
|
+
[event],
|
|
69
|
+
],
|
|
70
|
+
{ onHalt: (ended): ReadonlyArray<RunEvent> => (ended ? [] : [{ type: "run-completed" }]) },
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
/** End the run with `run-stopped` when `signal` completes (e.g. a stop `Deferred`). */
|
|
75
|
+
export const stopWhen =
|
|
76
|
+
(signal: Effect.Effect<void>) =>
|
|
77
|
+
<E, R>(self: Stream.Stream<RunEvent, E, R>): Stream.Stream<RunEvent, E, R> =>
|
|
78
|
+
self.pipe(
|
|
79
|
+
Stream.merge(Stream.fromEffect(Effect.as(signal, { type: "run-stopped" } as const)), {
|
|
80
|
+
haltStrategy: "left",
|
|
81
|
+
}),
|
|
82
|
+
Stream.takeUntil((event) => event.type === "run-stopped"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
/** A `run-failed` for a failed stream: `reason` is the error's `_tag`, else `"internal"`. */
|
|
86
|
+
export const failed = (cause: Cause.Cause<unknown>): RunEvent => {
|
|
87
|
+
const error = Cause.squash(cause)
|
|
88
|
+
const reason =
|
|
89
|
+
Predicate.hasProperty(error, "_tag") && Predicate.isString(error._tag) ? error._tag : "internal"
|
|
90
|
+
return { type: "run-failed", reason }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// sequence
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export const SequenceState = Schema.Struct({
|
|
98
|
+
seq: Schema.Int,
|
|
99
|
+
/** Id of the run currently open on this stream. */
|
|
100
|
+
runId: Schema.optionalKey(RunId),
|
|
101
|
+
})
|
|
102
|
+
export type SequenceState = typeof SequenceState.Type
|
|
103
|
+
|
|
104
|
+
const sequenceStep =
|
|
105
|
+
(streamId: StreamId) =>
|
|
106
|
+
(s: SequenceState, event: RunEvent): readonly [SequenceState, ReadonlyArray<Envelope>] => {
|
|
107
|
+
const runId = event.type === "run-started" ? event.runId : s.runId
|
|
108
|
+
const seq = s.seq + 1
|
|
109
|
+
const envelope: Envelope =
|
|
110
|
+
runId === undefined ? { streamId, seq, event } : { streamId, seq, runId, event }
|
|
111
|
+
const next: SequenceState = runId === undefined || isTerminal(event) ? { seq } : { seq, runId }
|
|
112
|
+
return [next, [envelope]]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Wrap events in envelopes with a per-stream `seq` (starting after `from`,
|
|
117
|
+
* default 0) and the current run's id, taken from the last `run-started`.
|
|
118
|
+
*/
|
|
119
|
+
export const sequence =
|
|
120
|
+
(streamId: StreamId, options?: { readonly from?: number }) =>
|
|
121
|
+
<E, R>(self: Stream.Stream<RunEvent, E, R>): Stream.Stream<Envelope, E, R> =>
|
|
122
|
+
self.pipe(
|
|
123
|
+
Stream.mapAccum((): SequenceState => ({ seq: options?.from ?? 0 }), sequenceStep(streamId)),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// coalesce
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
const merge = (a: RunEvent, b: RunEvent): RunEvent | undefined => {
|
|
131
|
+
if (a.type === "text-delta" && b.type === "text-delta") return { ...a, text: a.text + b.text }
|
|
132
|
+
if (a.type === "refusal-delta" && b.type === "refusal-delta")
|
|
133
|
+
return { ...a, text: a.text + b.text }
|
|
134
|
+
if (a.type === "reasoning-delta" && b.type === "reasoning-delta" && a.kind === b.kind) {
|
|
135
|
+
return { ...a, text: a.text + b.text }
|
|
136
|
+
}
|
|
137
|
+
if (
|
|
138
|
+
a.type === "tool-call-args-delta" &&
|
|
139
|
+
b.type === "tool-call-args-delta" &&
|
|
140
|
+
a.callId === b.callId
|
|
141
|
+
) {
|
|
142
|
+
return { ...a, delta: a.delta + b.delta }
|
|
143
|
+
}
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const mergeAdjacent = (group: ReadonlyArray<RunEvent>): ReadonlyArray<RunEvent> =>
|
|
148
|
+
Arr.reduce(group, Arr.empty<RunEvent>(), (acc: ReadonlyArray<RunEvent>, event) => {
|
|
149
|
+
const last = acc.at(-1)
|
|
150
|
+
const merged = last === undefined ? undefined : merge(last, event)
|
|
151
|
+
return merged === undefined ? [...acc, event] : [...acc.slice(0, -1), merged]
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Merge consecutive text, reasoning and tool-argument deltas that arrive
|
|
156
|
+
* within `window`; every other event passes through unchanged and in order.
|
|
157
|
+
* Bounds frames on the wire and rows in a durable log; adds up to `window`
|
|
158
|
+
* latency.
|
|
159
|
+
*/
|
|
160
|
+
export const coalesce =
|
|
161
|
+
(window: Duration.Input) =>
|
|
162
|
+
<E, R>(self: Stream.Stream<RunEvent, E, R>): Stream.Stream<RunEvent, E, R> =>
|
|
163
|
+
self.pipe(
|
|
164
|
+
Stream.groupedWithin(1024, window),
|
|
165
|
+
Stream.flatMap((group) => Stream.fromIterable(mergeAdjacent(group))),
|
|
166
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it } from "@effect/vitest"
|
|
2
|
+
import { Effect, Exit, Schema } from "effect"
|
|
3
|
+
import { expect } from "vitest"
|
|
4
|
+
import * as RunEvent from "./RunEvent.js"
|
|
5
|
+
|
|
6
|
+
const Wire = Schema.fromJsonString(RunEvent.EnvelopeJson)
|
|
7
|
+
const encode = Schema.encodeEffect(Wire)
|
|
8
|
+
const decode = Schema.decodeUnknownEffect(Wire)
|
|
9
|
+
|
|
10
|
+
const r1 = RunEvent.RunId.make("r1")
|
|
11
|
+
|
|
12
|
+
const envelope = (seq: number, event: RunEvent.RunEvent): RunEvent.Envelope => ({
|
|
13
|
+
streamId: RunEvent.StreamId.make("st_1"),
|
|
14
|
+
seq,
|
|
15
|
+
runId: r1,
|
|
16
|
+
event,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe("RunEvent wire codec", () => {
|
|
20
|
+
it.effect.each([
|
|
21
|
+
{
|
|
22
|
+
label: "run-started",
|
|
23
|
+
frame: envelope(1, {
|
|
24
|
+
type: "run-started",
|
|
25
|
+
runId: r1,
|
|
26
|
+
inputs: [RunEvent.InputId.make("in_1")],
|
|
27
|
+
protocol: 2,
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
label: "tool-call-ready",
|
|
32
|
+
frame: envelope(2, {
|
|
33
|
+
type: "tool-call-ready",
|
|
34
|
+
callId: "c1",
|
|
35
|
+
name: "get_weather",
|
|
36
|
+
input: { city: "Lisbon" },
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: "image bytes",
|
|
41
|
+
frame: envelope(3, {
|
|
42
|
+
type: "image",
|
|
43
|
+
image: { _tag: "bytes", bytes: new Uint8Array([137, 80, 78, 71]), mimeType: "image/png" },
|
|
44
|
+
}),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
label: "object-delta patch",
|
|
48
|
+
frame: envelope(4, {
|
|
49
|
+
type: "object-delta",
|
|
50
|
+
id: "o1",
|
|
51
|
+
patch: [{ op: "add", path: "/items/-", value: "eggs" }],
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
label: "run-completed",
|
|
56
|
+
frame: envelope(5, { type: "run-completed", metadata: { usage: { input_tokens: 10 } } }),
|
|
57
|
+
},
|
|
58
|
+
])("round-trips $label through JSON", ({ frame }) =>
|
|
59
|
+
encode(frame).pipe(
|
|
60
|
+
Effect.flatMap(decode),
|
|
61
|
+
Effect.map((decoded) => expect(decoded).toEqual(frame)),
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
it.effect.each([
|
|
66
|
+
{ label: "an unknown event type", frame: { streamId: "s", seq: 1, event: { type: "nope" } } },
|
|
67
|
+
{
|
|
68
|
+
label: "another protocol version",
|
|
69
|
+
frame: {
|
|
70
|
+
streamId: "s",
|
|
71
|
+
seq: 1,
|
|
72
|
+
event: { type: "run-started", runId: "r1", inputs: [], protocol: 1 },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
label: "a seq below 1",
|
|
77
|
+
frame: { streamId: "s", seq: 0, event: { type: "text-delta", text: "hi" } },
|
|
78
|
+
},
|
|
79
|
+
])("rejects $label", ({ frame }) =>
|
|
80
|
+
Effect.exit(decode(JSON.stringify(frame))).pipe(
|
|
81
|
+
Effect.map((exit) => expect(Exit.isFailure(exit)).toBe(true)),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
})
|
package/src/RunEvent.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `@effect-uai/ui` wire protocol (v1): what a server streams to a client
|
|
3
|
+
* about agent runs. Every frame is an `Envelope`; `seq` is per stream,
|
|
4
|
+
* starts at 1 and never repeats, so clients dedupe and resume by it.
|
|
5
|
+
*
|
|
6
|
+
* A run starts with `run-started` and ends with exactly one terminal
|
|
7
|
+
* (`run-completed`, `run-interrupted`, `run-failed`, `run-stopped`). Field
|
|
8
|
+
* names are camelCase; the event discriminator is `type`.
|
|
9
|
+
*/
|
|
10
|
+
import * as Img from "@effect-uai/core/Image"
|
|
11
|
+
import * as Items from "@effect-uai/core/Items"
|
|
12
|
+
import { Schema } from "effect"
|
|
13
|
+
|
|
14
|
+
export const PROTOCOL_VERSION = 2
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Ids: branded so a stream, run and input id can't be swapped. Wire decoding
|
|
18
|
+
// brands them; build one by hand with `StreamId.make("st_1")`.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export const StreamId = Schema.String.pipe(Schema.brand("StreamId"))
|
|
22
|
+
export type StreamId = typeof StreamId.Type
|
|
23
|
+
|
|
24
|
+
export const RunId = Schema.String.pipe(Schema.brand("RunId"))
|
|
25
|
+
export type RunId = typeof RunId.Type
|
|
26
|
+
|
|
27
|
+
/** Client-generated id of one input. */
|
|
28
|
+
export const InputId = Schema.String.pipe(Schema.brand("InputId"))
|
|
29
|
+
export type InputId = typeof InputId.Type
|
|
30
|
+
|
|
31
|
+
const event = <const T extends string, const F extends Schema.Struct.Fields>(type: T, fields: F) =>
|
|
32
|
+
Schema.Struct({ type: Schema.Literal(type), ...fields })
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Run lifecycle
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export const RunStarted = event("run-started", {
|
|
39
|
+
runId: RunId,
|
|
40
|
+
/** Role of the run when a stream carries several, e.g. "assistant", "reviewer". */
|
|
41
|
+
name: Schema.optional(Schema.String),
|
|
42
|
+
/** Client input ids this run consumed, for optimistic-UI reconciliation. */
|
|
43
|
+
inputs: Schema.Array(InputId),
|
|
44
|
+
protocol: Schema.Literal(PROTOCOL_VERSION),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** A pause point the client must answer (today: a tool call awaiting approval). */
|
|
48
|
+
export const Interrupt = Schema.Struct({
|
|
49
|
+
callId: Schema.String,
|
|
50
|
+
reason: Schema.Literal("approval"),
|
|
51
|
+
name: Schema.String,
|
|
52
|
+
input: Schema.Json,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const RunCompleted = event("run-completed", { metadata: Schema.optional(Schema.Json) })
|
|
56
|
+
export const RunInterrupted = event("run-interrupted", { interrupts: Schema.Array(Interrupt) })
|
|
57
|
+
/** `reason` is a short code (an error's `_tag`); whether to offer a retry is the client's call. */
|
|
58
|
+
export const RunFailed = event("run-failed", {
|
|
59
|
+
reason: Schema.String,
|
|
60
|
+
message: Schema.optional(Schema.String),
|
|
61
|
+
})
|
|
62
|
+
export const RunStopped = event("run-stopped", {})
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Steps (one per model turn) and content
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
export const StepStarted = event("step-started", {})
|
|
69
|
+
export const StepFinished = event("step-finished", { usage: Schema.optional(Items.Usage) })
|
|
70
|
+
/** Discard the current step's partial output (mid-stream fallback or retry). */
|
|
71
|
+
export const StepReset = event("step-reset", {})
|
|
72
|
+
|
|
73
|
+
export const TextDelta = event("text-delta", { text: Schema.String })
|
|
74
|
+
/** The model declined to answer; distinct from text (core's `refusal` block). */
|
|
75
|
+
export const RefusalDelta = event("refusal-delta", { text: Schema.String })
|
|
76
|
+
export const ReasoningStarted = event("reasoning-started", {})
|
|
77
|
+
export const ReasoningDelta = event("reasoning-delta", {
|
|
78
|
+
text: Schema.String,
|
|
79
|
+
kind: Schema.Literals(["trace", "summary"]),
|
|
80
|
+
})
|
|
81
|
+
export const Citation = event("citation", { annotation: Items.Annotation })
|
|
82
|
+
export const Image = event("image", {
|
|
83
|
+
image: Img.ImageSource,
|
|
84
|
+
/** Set only on a preview frame; absent means final. */
|
|
85
|
+
partialIndex: Schema.optional(Schema.Number),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Structured objects
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
const JsonPatchOperation = Schema.Union([
|
|
93
|
+
Schema.Struct({ op: Schema.Literal("add"), path: Schema.String, value: Schema.Json }),
|
|
94
|
+
Schema.Struct({ op: Schema.Literal("remove"), path: Schema.String }),
|
|
95
|
+
Schema.Struct({ op: Schema.Literal("replace"), path: Schema.String, value: Schema.Json }),
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
export const ObjectStarted = event("object-started", {
|
|
99
|
+
id: Schema.String,
|
|
100
|
+
/** Which schema or list the object belongs to. */
|
|
101
|
+
name: Schema.optional(Schema.String),
|
|
102
|
+
})
|
|
103
|
+
/** Either a JSON Patch against the previous partial value, or a snapshot `value`. */
|
|
104
|
+
export const ObjectDelta = event("object-delta", {
|
|
105
|
+
id: Schema.String,
|
|
106
|
+
patch: Schema.optional(Schema.Array(JsonPatchOperation)),
|
|
107
|
+
value: Schema.optional(Schema.Json),
|
|
108
|
+
})
|
|
109
|
+
export const ObjectDone = event("object-done", {
|
|
110
|
+
id: Schema.String,
|
|
111
|
+
/** The object's name again, so a client can decode `value` without state. */
|
|
112
|
+
name: Schema.optional(Schema.String),
|
|
113
|
+
value: Schema.Json,
|
|
114
|
+
})
|
|
115
|
+
export const ObjectFailed = event("object-failed", {
|
|
116
|
+
id: Schema.String,
|
|
117
|
+
issues: Schema.Array(
|
|
118
|
+
Schema.Struct({
|
|
119
|
+
path: Schema.Array(Schema.Union([Schema.String, Schema.Number])),
|
|
120
|
+
message: Schema.String,
|
|
121
|
+
}),
|
|
122
|
+
),
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Tools
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
export const ToolCallStarted = event("tool-call-started", {
|
|
130
|
+
callId: Schema.String,
|
|
131
|
+
name: Schema.String,
|
|
132
|
+
})
|
|
133
|
+
export const ToolCallArgsDelta = event("tool-call-args-delta", {
|
|
134
|
+
callId: Schema.String,
|
|
135
|
+
delta: Schema.String,
|
|
136
|
+
})
|
|
137
|
+
export const ToolCallReady = event("tool-call-ready", {
|
|
138
|
+
callId: Schema.String,
|
|
139
|
+
name: Schema.String,
|
|
140
|
+
input: Schema.Json,
|
|
141
|
+
})
|
|
142
|
+
/** The run is waiting in-process for a decision on this call (it keeps running). */
|
|
143
|
+
export const ToolApprovalRequested = event("tool-approval-requested", {
|
|
144
|
+
callId: Schema.String,
|
|
145
|
+
name: Schema.String,
|
|
146
|
+
input: Schema.Json,
|
|
147
|
+
})
|
|
148
|
+
/** An intermediate value of a streaming tool, encoded like its output. */
|
|
149
|
+
export const ToolPartial = event("tool-partial", {
|
|
150
|
+
callId: Schema.String,
|
|
151
|
+
name: Schema.String,
|
|
152
|
+
value: Schema.Json,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
/** How a call ended: its output, or an error (the tool's, core's, or the app's). */
|
|
156
|
+
export const outcome = <V extends Schema.Top, E extends Schema.Top>(value: V, error: E) =>
|
|
157
|
+
Schema.Union([
|
|
158
|
+
Schema.TaggedStruct("Success", { value }),
|
|
159
|
+
Schema.TaggedStruct("Failure", { error }),
|
|
160
|
+
])
|
|
161
|
+
|
|
162
|
+
/** An outcome as sent: value and error are JSON. */
|
|
163
|
+
export const Outcome = outcome(Schema.Json, Schema.Json)
|
|
164
|
+
|
|
165
|
+
export type Outcome<V = Json, E = Json> =
|
|
166
|
+
| { readonly _tag: "Success"; readonly value: V }
|
|
167
|
+
| { readonly _tag: "Failure"; readonly error: E }
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Every call ends with one `tool-result`: calls that ran, and calls the app
|
|
171
|
+
* closed itself (denied, cancelled, answered by the user).
|
|
172
|
+
*/
|
|
173
|
+
export const ToolResult = event("tool-result", {
|
|
174
|
+
callId: Schema.String,
|
|
175
|
+
/** The tool's name, so a client can decode `result` without state. */
|
|
176
|
+
name: Schema.String,
|
|
177
|
+
result: Outcome,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Usage and app payloads
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
export const Usage = event("usage", { usage: Items.Usage })
|
|
185
|
+
/** Persisted app payload; a repeated `id` replaces the earlier one in place. */
|
|
186
|
+
export const Data = event("data", {
|
|
187
|
+
name: Schema.String,
|
|
188
|
+
value: Schema.Json,
|
|
189
|
+
id: Schema.optional(Schema.String),
|
|
190
|
+
})
|
|
191
|
+
/** Transient app payload (metrics, status); latest value wins, never stored in a message. */
|
|
192
|
+
export const Live = event("live", { name: Schema.String, value: Schema.Json })
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Stream-scoped: the server accepted an input (queued or about to run). Lets a
|
|
196
|
+
* reloaded client show inputs that wait for their run. `input` is the app's
|
|
197
|
+
* encoded input (e.g. `Input.InputJson`); its run's `run-started` lists its id.
|
|
198
|
+
*/
|
|
199
|
+
export const InputAccepted = event("input-accepted", { input: Schema.Json })
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Unions and envelope
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/** Events whose payloads a contract types (see `UiContract`). */
|
|
206
|
+
export const payloadEvents = [
|
|
207
|
+
RunCompleted,
|
|
208
|
+
RunInterrupted,
|
|
209
|
+
ObjectDone,
|
|
210
|
+
ToolCallReady,
|
|
211
|
+
ToolApprovalRequested,
|
|
212
|
+
ToolPartial,
|
|
213
|
+
ToolResult,
|
|
214
|
+
Data,
|
|
215
|
+
Live,
|
|
216
|
+
] as const
|
|
217
|
+
|
|
218
|
+
/** Events a contract leaves as they are. */
|
|
219
|
+
export const plainEvents = [
|
|
220
|
+
RunStarted,
|
|
221
|
+
RunFailed,
|
|
222
|
+
RunStopped,
|
|
223
|
+
StepStarted,
|
|
224
|
+
StepFinished,
|
|
225
|
+
StepReset,
|
|
226
|
+
TextDelta,
|
|
227
|
+
RefusalDelta,
|
|
228
|
+
ReasoningStarted,
|
|
229
|
+
ReasoningDelta,
|
|
230
|
+
Citation,
|
|
231
|
+
Image,
|
|
232
|
+
ObjectStarted,
|
|
233
|
+
ObjectDelta,
|
|
234
|
+
ObjectFailed,
|
|
235
|
+
ToolCallStarted,
|
|
236
|
+
ToolCallArgsDelta,
|
|
237
|
+
Usage,
|
|
238
|
+
InputAccepted,
|
|
239
|
+
] as const
|
|
240
|
+
|
|
241
|
+
/** The protocol as sent: payloads are JSON. A contract decodes them (`UiContract`). */
|
|
242
|
+
export const RunEvent = Schema.Union([...plainEvents, ...payloadEvents])
|
|
243
|
+
|
|
244
|
+
export const envelopeFields = {
|
|
245
|
+
streamId: StreamId,
|
|
246
|
+
seq: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
|
|
247
|
+
/** Set on run-scoped events; absent on stream-scoped ones. */
|
|
248
|
+
runId: Schema.optional(RunId),
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const Envelope = Schema.Struct({ ...envelopeFields, event: RunEvent })
|
|
252
|
+
|
|
253
|
+
/** JSON codec for the wire: `Uint8Array` image bytes and other non-JSON values encode safely. */
|
|
254
|
+
export const EnvelopeJson = Schema.toCodecJson(Envelope)
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Payload types: what a contract types. `P` defaults to `Untyped` (JSON as sent).
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
export type Json = Schema.Json
|
|
261
|
+
|
|
262
|
+
export type ToolPayload = {
|
|
263
|
+
readonly name: string
|
|
264
|
+
readonly input: unknown
|
|
265
|
+
readonly output: unknown
|
|
266
|
+
/** Every error a call of this tool can end with. */
|
|
267
|
+
readonly failure: unknown
|
|
268
|
+
}
|
|
269
|
+
export type NamedPayload = { readonly name: string; readonly value: unknown }
|
|
270
|
+
|
|
271
|
+
/** Typed payloads: each field is a union with one member per name. */
|
|
272
|
+
export type Payloads = {
|
|
273
|
+
readonly tool: ToolPayload
|
|
274
|
+
readonly object: NamedPayload
|
|
275
|
+
readonly data: NamedPayload
|
|
276
|
+
readonly live: NamedPayload
|
|
277
|
+
readonly metadata: unknown
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** No contract: any name, payloads as sent. */
|
|
281
|
+
export type Untyped = {
|
|
282
|
+
readonly tool: {
|
|
283
|
+
readonly name: string
|
|
284
|
+
readonly input: Json
|
|
285
|
+
readonly output: Json
|
|
286
|
+
readonly failure: Json
|
|
287
|
+
}
|
|
288
|
+
readonly object: { readonly name: string; readonly value: Json }
|
|
289
|
+
readonly data: { readonly name: string; readonly value: Json }
|
|
290
|
+
readonly live: { readonly name: string; readonly value: Json }
|
|
291
|
+
readonly metadata: Json
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
type Plain = (typeof plainEvents)[number]["Type"]
|
|
295
|
+
|
|
296
|
+
// Distributes over the tool union, so narrowing by `name` types `value` and `result`.
|
|
297
|
+
type ToolEvents<T> = T extends ToolPayload
|
|
298
|
+
?
|
|
299
|
+
| {
|
|
300
|
+
readonly type: "tool-partial"
|
|
301
|
+
readonly callId: string
|
|
302
|
+
readonly name: T["name"]
|
|
303
|
+
readonly value: T["output"]
|
|
304
|
+
}
|
|
305
|
+
| {
|
|
306
|
+
readonly type: "tool-result"
|
|
307
|
+
readonly callId: string
|
|
308
|
+
readonly name: T["name"]
|
|
309
|
+
readonly result: Outcome<T["output"], T["failure"]>
|
|
310
|
+
}
|
|
311
|
+
: never
|
|
312
|
+
|
|
313
|
+
export type Interrupt<P extends Payloads = Untyped> = {
|
|
314
|
+
readonly callId: string
|
|
315
|
+
readonly reason: "approval"
|
|
316
|
+
readonly name: P["tool"]["name"]
|
|
317
|
+
readonly input: P["tool"]["input"]
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
type Typed<P extends Payloads> =
|
|
321
|
+
| { readonly type: "run-completed"; readonly metadata?: P["metadata"] | undefined }
|
|
322
|
+
| { readonly type: "run-interrupted"; readonly interrupts: ReadonlyArray<Interrupt<P>> }
|
|
323
|
+
| {
|
|
324
|
+
readonly type: "object-done"
|
|
325
|
+
readonly id: string
|
|
326
|
+
readonly name?: P["object"]["name"] | undefined
|
|
327
|
+
readonly value: P["object"]["value"]
|
|
328
|
+
}
|
|
329
|
+
| {
|
|
330
|
+
readonly type: "tool-call-ready"
|
|
331
|
+
readonly callId: string
|
|
332
|
+
readonly name: P["tool"]["name"]
|
|
333
|
+
readonly input: P["tool"]["input"]
|
|
334
|
+
}
|
|
335
|
+
| {
|
|
336
|
+
readonly type: "tool-approval-requested"
|
|
337
|
+
readonly callId: string
|
|
338
|
+
readonly name: P["tool"]["name"]
|
|
339
|
+
readonly input: P["tool"]["input"]
|
|
340
|
+
}
|
|
341
|
+
| ToolEvents<P["tool"]>
|
|
342
|
+
| {
|
|
343
|
+
readonly type: "data"
|
|
344
|
+
readonly name: P["data"]["name"]
|
|
345
|
+
readonly value: P["data"]["value"]
|
|
346
|
+
readonly id?: string | undefined
|
|
347
|
+
}
|
|
348
|
+
| { readonly type: "live"; readonly name: P["live"]["name"]; readonly value: P["live"]["value"] }
|
|
349
|
+
|
|
350
|
+
/** One event; with a contract's `P`, its payloads are decoded. */
|
|
351
|
+
export type RunEvent<P extends Payloads = Untyped> = Plain | Typed<P>
|
|
352
|
+
|
|
353
|
+
export type Envelope<P extends Payloads = Untyped> = {
|
|
354
|
+
readonly streamId: StreamId
|
|
355
|
+
readonly seq: number
|
|
356
|
+
readonly runId?: RunId | undefined
|
|
357
|
+
readonly event: RunEvent<P>
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export type Terminal<P extends Payloads = Untyped> = Extract<
|
|
361
|
+
RunEvent<P>,
|
|
362
|
+
{ readonly type: "run-completed" | "run-interrupted" | "run-failed" | "run-stopped" }
|
|
363
|
+
>
|
|
364
|
+
|
|
365
|
+
const terminalTypes: ReadonlySet<string> = new Set([
|
|
366
|
+
"run-completed",
|
|
367
|
+
"run-interrupted",
|
|
368
|
+
"run-failed",
|
|
369
|
+
"run-stopped",
|
|
370
|
+
])
|
|
371
|
+
|
|
372
|
+
export const isTerminal = <P extends Payloads = Untyped>(
|
|
373
|
+
event: RunEvent<P>,
|
|
374
|
+
): event is Terminal<P> => terminalTypes.has(event.type)
|