@batonfx/foldkit 0.1.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/.turbo/turbo-build.log +5 -0
- package/dist/index.js +39540 -0
- package/package.json +31 -0
- package/src/chat.ts +630 -0
- package/src/connection.ts +133 -0
- package/src/index.ts +2 -0
- package/test/chat.test.ts +146 -0
- package/test/connection.test.ts +35 -0
- package/tsconfig.json +7 -0
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
|
+
"name": "@batonfx/foldkit",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "bun build ./src/index.ts --outdir dist --target bun --format esm",
|
|
12
|
+
"lint": "bun --cwd ../.. oxlint packages/foldkit/src packages/foldkit/test",
|
|
13
|
+
"test": "bun --cwd ../.. vitest run packages/foldkit/test --passWithNoTests",
|
|
14
|
+
"typecheck": "bun tsc --noEmit"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@batonfx/transport": "0.1.0"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"effect": ">=4.0.0-beta.88 <4.0.1",
|
|
21
|
+
"foldkit": ">=0.122.0 <1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@effect/vitest": "4.0.0-beta.93",
|
|
25
|
+
"@types/bun": "1.3.13",
|
|
26
|
+
"effect": "4.0.0-beta.93",
|
|
27
|
+
"foldkit": "0.122.0",
|
|
28
|
+
"typescript": "5.8.2",
|
|
29
|
+
"vitest": "4.0.16"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/chat.ts
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import { Cause, Equivalence, Effect, Option, Schema, Stream } from "effect"
|
|
2
|
+
import * as Ai from "effect/unstable/ai"
|
|
3
|
+
import * as Command from "foldkit/command"
|
|
4
|
+
import { m } from "foldkit/message"
|
|
5
|
+
import type { CallableTaggedStruct } from "foldkit/schema"
|
|
6
|
+
import * as Subscription from "foldkit/subscription"
|
|
7
|
+
import { Wire } from "@batonfx/transport"
|
|
8
|
+
import * as Connection from "./connection"
|
|
9
|
+
|
|
10
|
+
const CompletedFields = { isFailure: Schema.Boolean, result: Schema.Unknown }
|
|
11
|
+
const UserEntryFields = { text: Schema.String }
|
|
12
|
+
const AssistantEntryFields = { text: Schema.String, reasoning: Schema.NullOr(Schema.String) }
|
|
13
|
+
const RunCompletedFields = { text: Schema.String }
|
|
14
|
+
const StringReasonFields = { reason: Schema.String }
|
|
15
|
+
const ToolEntryFields = {
|
|
16
|
+
callId: Schema.String,
|
|
17
|
+
name: Schema.String,
|
|
18
|
+
params: Schema.Unknown,
|
|
19
|
+
outcome: Schema.suspend((): Schema.Schema<ToolOutcome> => ToolOutcome),
|
|
20
|
+
progress: Schema.Array(Schema.String),
|
|
21
|
+
}
|
|
22
|
+
const RunningFields = { turn: Schema.Number }
|
|
23
|
+
const AwaitingApprovalFields = {
|
|
24
|
+
token: Schema.String,
|
|
25
|
+
toolName: Schema.String,
|
|
26
|
+
params: Schema.Unknown,
|
|
27
|
+
}
|
|
28
|
+
const ClickedDenyFields = { reason: Schema.NullOr(Schema.String) }
|
|
29
|
+
const ReceivedAgentFields = { incoming: Connection.Incoming }
|
|
30
|
+
const ModelStreaming = Schema.Struct({
|
|
31
|
+
turn: Schema.Number,
|
|
32
|
+
text: Schema.String,
|
|
33
|
+
reasoning: Schema.String,
|
|
34
|
+
})
|
|
35
|
+
const ModelConnection = Schema.Literals(["disconnected", "connecting", "open", "reconnecting"])
|
|
36
|
+
|
|
37
|
+
const Pending: CallableTaggedStruct<"Pending", {}> = m("Pending")
|
|
38
|
+
const Completed: CallableTaggedStruct<"Completed", typeof CompletedFields> = m("Completed", CompletedFields)
|
|
39
|
+
|
|
40
|
+
/** @experimental */
|
|
41
|
+
export type ToolOutcome = typeof Pending.Type | typeof Completed.Type
|
|
42
|
+
|
|
43
|
+
/** @experimental */
|
|
44
|
+
export const ToolOutcome: Schema.Schema<ToolOutcome> = Schema.Union([Pending, Completed])
|
|
45
|
+
|
|
46
|
+
/** @experimental */
|
|
47
|
+
export const UserEntry: CallableTaggedStruct<"UserEntry", typeof UserEntryFields> = m("UserEntry", UserEntryFields)
|
|
48
|
+
|
|
49
|
+
/** @experimental */
|
|
50
|
+
export const AssistantEntry: CallableTaggedStruct<"AssistantEntry", typeof AssistantEntryFields> = m(
|
|
51
|
+
"AssistantEntry",
|
|
52
|
+
AssistantEntryFields,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
/** @experimental */
|
|
56
|
+
export const ToolEntry: CallableTaggedStruct<"ToolEntry", typeof ToolEntryFields> = m("ToolEntry", ToolEntryFields)
|
|
57
|
+
|
|
58
|
+
/** @experimental */
|
|
59
|
+
export type ChatEntry = typeof UserEntry.Type | typeof AssistantEntry.Type | typeof ToolEntry.Type
|
|
60
|
+
|
|
61
|
+
/** @experimental */
|
|
62
|
+
export const ChatEntry: Schema.Schema<ChatEntry> = Schema.Union([UserEntry, AssistantEntry, ToolEntry])
|
|
63
|
+
|
|
64
|
+
/** @experimental */
|
|
65
|
+
export const Idle: CallableTaggedStruct<"Idle", {}> = m("Idle")
|
|
66
|
+
|
|
67
|
+
/** @experimental */
|
|
68
|
+
export const Running: CallableTaggedStruct<"Running", typeof RunningFields> = m("Running", RunningFields)
|
|
69
|
+
|
|
70
|
+
/** @experimental */
|
|
71
|
+
export const AwaitingApproval: CallableTaggedStruct<"AwaitingApproval", typeof AwaitingApprovalFields> = m(
|
|
72
|
+
"AwaitingApproval",
|
|
73
|
+
AwaitingApprovalFields,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
/** @experimental */
|
|
77
|
+
export const Failed: CallableTaggedStruct<"Failed", { message: typeof Schema.String }> = m("Failed", {
|
|
78
|
+
message: Schema.String,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/** @experimental */
|
|
82
|
+
export type RunState = typeof Idle.Type | typeof Running.Type | typeof AwaitingApproval.Type | typeof Failed.Type
|
|
83
|
+
|
|
84
|
+
/** @experimental */
|
|
85
|
+
export const RunState: Schema.Schema<RunState> = Schema.Union([Idle, Running, AwaitingApproval, Failed])
|
|
86
|
+
|
|
87
|
+
/** @experimental */
|
|
88
|
+
export interface Model {
|
|
89
|
+
readonly sessionId: string | null
|
|
90
|
+
readonly connection: typeof ModelConnection.Type
|
|
91
|
+
readonly lastSeq: number
|
|
92
|
+
readonly run: RunState
|
|
93
|
+
readonly entries: ReadonlyArray<ChatEntry>
|
|
94
|
+
readonly streaming: typeof ModelStreaming.Type | null
|
|
95
|
+
readonly draft: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @experimental */
|
|
99
|
+
export const Model: Schema.Schema<Model> = Schema.Struct({
|
|
100
|
+
sessionId: Schema.NullOr(Schema.String),
|
|
101
|
+
connection: ModelConnection,
|
|
102
|
+
lastSeq: Schema.Number,
|
|
103
|
+
run: RunState,
|
|
104
|
+
entries: Schema.Array(ChatEntry),
|
|
105
|
+
streaming: Schema.NullOr(ModelStreaming),
|
|
106
|
+
draft: Schema.String,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/** @experimental */
|
|
110
|
+
export const ReceivedAgent: CallableTaggedStruct<"ReceivedAgent", typeof ReceivedAgentFields> = m(
|
|
111
|
+
"ReceivedAgent",
|
|
112
|
+
ReceivedAgentFields,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
/** @experimental */
|
|
116
|
+
export const ChangedDraft: CallableTaggedStruct<"ChangedDraft", typeof UserEntryFields> = m(
|
|
117
|
+
"ChangedDraft",
|
|
118
|
+
UserEntryFields,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
/** @experimental */
|
|
122
|
+
export const SubmittedMessage: CallableTaggedStruct<"SubmittedMessage", {}> = m("SubmittedMessage")
|
|
123
|
+
|
|
124
|
+
/** @experimental */
|
|
125
|
+
export const ClickedCancel: CallableTaggedStruct<"ClickedCancel", {}> = m("ClickedCancel")
|
|
126
|
+
|
|
127
|
+
/** @experimental */
|
|
128
|
+
export const ClickedApprove: CallableTaggedStruct<"ClickedApprove", {}> = m("ClickedApprove")
|
|
129
|
+
|
|
130
|
+
/** @experimental */
|
|
131
|
+
export const ClickedDeny: CallableTaggedStruct<"ClickedDeny", typeof ClickedDenyFields> = m(
|
|
132
|
+
"ClickedDeny",
|
|
133
|
+
ClickedDenyFields,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
/** @experimental */
|
|
137
|
+
export const SentUserMessage: CallableTaggedStruct<"SentUserMessage", {}> = m("SentUserMessage")
|
|
138
|
+
|
|
139
|
+
/** @experimental */
|
|
140
|
+
export const ResolvedApproval: CallableTaggedStruct<"ResolvedApproval", {}> = m("ResolvedApproval")
|
|
141
|
+
|
|
142
|
+
/** @experimental */
|
|
143
|
+
export const CancelledRun: CallableTaggedStruct<"CancelledRun", {}> = m("CancelledRun")
|
|
144
|
+
|
|
145
|
+
/** @experimental */
|
|
146
|
+
export const FailedAgentCommand: CallableTaggedStruct<"FailedAgentCommand", typeof StringReasonFields> = m(
|
|
147
|
+
"FailedAgentCommand",
|
|
148
|
+
StringReasonFields,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
/** @experimental */
|
|
152
|
+
export type Message =
|
|
153
|
+
| typeof ReceivedAgent.Type
|
|
154
|
+
| typeof ChangedDraft.Type
|
|
155
|
+
| typeof SubmittedMessage.Type
|
|
156
|
+
| typeof ClickedCancel.Type
|
|
157
|
+
| typeof ClickedApprove.Type
|
|
158
|
+
| typeof ClickedDeny.Type
|
|
159
|
+
| typeof SentUserMessage.Type
|
|
160
|
+
| typeof ResolvedApproval.Type
|
|
161
|
+
| typeof CancelledRun.Type
|
|
162
|
+
| typeof FailedAgentCommand.Type
|
|
163
|
+
|
|
164
|
+
/** @experimental */
|
|
165
|
+
export const Message: Schema.Schema<Message> = Schema.Union([
|
|
166
|
+
ReceivedAgent,
|
|
167
|
+
ChangedDraft,
|
|
168
|
+
SubmittedMessage,
|
|
169
|
+
ClickedCancel,
|
|
170
|
+
ClickedApprove,
|
|
171
|
+
ClickedDeny,
|
|
172
|
+
SentUserMessage,
|
|
173
|
+
ResolvedApproval,
|
|
174
|
+
CancelledRun,
|
|
175
|
+
FailedAgentCommand,
|
|
176
|
+
])
|
|
177
|
+
|
|
178
|
+
/** @experimental */
|
|
179
|
+
export const RunCompleted: CallableTaggedStruct<"RunCompleted", typeof RunCompletedFields> = m(
|
|
180
|
+
"RunCompleted",
|
|
181
|
+
RunCompletedFields,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
/** @experimental */
|
|
185
|
+
export const ApprovalRequired: CallableTaggedStruct<"ApprovalRequired", {}> = m("ApprovalRequired")
|
|
186
|
+
|
|
187
|
+
/** @experimental */
|
|
188
|
+
export const RunFailed: CallableTaggedStruct<"RunFailed", { message: typeof Schema.String }> = m("RunFailed", {
|
|
189
|
+
message: Schema.String,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
/** @experimental */
|
|
193
|
+
export type OutMessage = typeof RunCompleted.Type | typeof ApprovalRequired.Type | typeof RunFailed.Type
|
|
194
|
+
|
|
195
|
+
/** @experimental */
|
|
196
|
+
export const OutMessage: Schema.Schema<OutMessage> = Schema.Union([RunCompleted, ApprovalRequired, RunFailed])
|
|
197
|
+
|
|
198
|
+
/** @experimental */
|
|
199
|
+
export type ChatCommand = Command.Command<Message, any, Connection.AgentConnection>
|
|
200
|
+
|
|
201
|
+
/** @experimental */
|
|
202
|
+
export const initialModel = (sessionId: string | null = null): Model => ({
|
|
203
|
+
sessionId,
|
|
204
|
+
connection: "disconnected",
|
|
205
|
+
lastSeq: -1,
|
|
206
|
+
run: Idle(),
|
|
207
|
+
entries: [],
|
|
208
|
+
streaming: null,
|
|
209
|
+
draft: "",
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
type FailedAgentCommandMessage = typeof FailedAgentCommand.Type
|
|
213
|
+
|
|
214
|
+
const commandFailed = (error: unknown): FailedAgentCommandMessage =>
|
|
215
|
+
FailedAgentCommand({ reason: error instanceof Connection.SendFailed ? error.reason : String(error) })
|
|
216
|
+
|
|
217
|
+
const catchCommandFailure = <A>(effect: Effect.Effect<A, Connection.SendFailed, Connection.AgentConnection>) =>
|
|
218
|
+
effect.pipe(Effect.catchCause((cause) => Effect.succeed(commandFailed(Cause.squash(cause)))))
|
|
219
|
+
|
|
220
|
+
/** @experimental */
|
|
221
|
+
export const SendUserMessage = Command.define(
|
|
222
|
+
"SendUserMessage",
|
|
223
|
+
{ sessionId: Schema.String, text: Schema.String },
|
|
224
|
+
SentUserMessage,
|
|
225
|
+
FailedAgentCommand,
|
|
226
|
+
)(({ sessionId, text }) =>
|
|
227
|
+
Connection.AgentConnection.use((connection) =>
|
|
228
|
+
catchCommandFailure(
|
|
229
|
+
connection.send({ _tag: "SendMessage", sessionId, prompt: text }).pipe(Effect.as(SentUserMessage())),
|
|
230
|
+
),
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
/** @experimental */
|
|
235
|
+
export const ResolveApproval = Command.define(
|
|
236
|
+
"ResolveApproval",
|
|
237
|
+
{
|
|
238
|
+
sessionId: Schema.String,
|
|
239
|
+
token: Schema.String,
|
|
240
|
+
approved: Schema.Boolean,
|
|
241
|
+
reason: Schema.NullOr(Schema.String),
|
|
242
|
+
},
|
|
243
|
+
ResolvedApproval,
|
|
244
|
+
FailedAgentCommand,
|
|
245
|
+
)(({ sessionId, token, approved, reason }) => {
|
|
246
|
+
const decision: Wire.ClientApproval = approved
|
|
247
|
+
? { _tag: "Approved" }
|
|
248
|
+
: reason === null
|
|
249
|
+
? { _tag: "Denied" }
|
|
250
|
+
: { _tag: "Denied", reason }
|
|
251
|
+
return Connection.AgentConnection.use((connection) =>
|
|
252
|
+
catchCommandFailure(
|
|
253
|
+
connection.send({ _tag: "ResolveApproval", sessionId, token, decision }).pipe(Effect.as(ResolvedApproval())),
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
/** @experimental */
|
|
259
|
+
export const CancelRun = Command.define(
|
|
260
|
+
"CancelRun",
|
|
261
|
+
{ sessionId: Schema.String },
|
|
262
|
+
CancelledRun,
|
|
263
|
+
FailedAgentCommand,
|
|
264
|
+
)(({ sessionId }) =>
|
|
265
|
+
Connection.AgentConnection.use((connection) =>
|
|
266
|
+
catchCommandFailure(connection.send({ _tag: "Cancel", sessionId }).pipe(Effect.as(CancelledRun()))),
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
interface StreamingState {
|
|
271
|
+
readonly turn: number
|
|
272
|
+
readonly text: string
|
|
273
|
+
readonly reasoning: string
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
interface ToolCallLike {
|
|
277
|
+
readonly type: "tool-call"
|
|
278
|
+
readonly id: string
|
|
279
|
+
readonly name: string
|
|
280
|
+
readonly params: unknown
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
interface ToolResultLike {
|
|
284
|
+
readonly type: "tool-result"
|
|
285
|
+
readonly id: string
|
|
286
|
+
readonly name: string
|
|
287
|
+
readonly result: unknown
|
|
288
|
+
readonly isFailure: boolean
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
292
|
+
typeof value === "object" && value !== null
|
|
293
|
+
|
|
294
|
+
const isToolCall = (value: unknown): value is ToolCallLike =>
|
|
295
|
+
isRecord(value) &&
|
|
296
|
+
value.type === "tool-call" &&
|
|
297
|
+
typeof value.id === "string" &&
|
|
298
|
+
typeof value.name === "string" &&
|
|
299
|
+
"params" in value
|
|
300
|
+
|
|
301
|
+
const isToolResult = (value: unknown): value is ToolResultLike =>
|
|
302
|
+
isRecord(value) &&
|
|
303
|
+
value.type === "tool-result" &&
|
|
304
|
+
typeof value.id === "string" &&
|
|
305
|
+
typeof value.name === "string" &&
|
|
306
|
+
"result" in value &&
|
|
307
|
+
typeof value.isFailure === "boolean"
|
|
308
|
+
|
|
309
|
+
const streamingFor = (model: Model, turn: number): StreamingState =>
|
|
310
|
+
model.streaming ?? { turn, text: "", reasoning: "" }
|
|
311
|
+
|
|
312
|
+
const appendStreaming = (model: Model, turn: number, field: "text" | "reasoning", delta: string): Model => {
|
|
313
|
+
const streaming = streamingFor(model, turn)
|
|
314
|
+
return { ...model, streaming: { ...streaming, [field]: streaming[field] + delta } }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const flushStreaming = (model: Model): Model => {
|
|
318
|
+
if (model.streaming === null) return model
|
|
319
|
+
const { text, reasoning } = model.streaming
|
|
320
|
+
const entry =
|
|
321
|
+
text.length === 0 && reasoning.length === 0 ? [] : [AssistantEntry({ text, reasoning: reasoning || null })]
|
|
322
|
+
return { ...model, entries: [...model.entries, ...entry], streaming: null }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const upsertToolCall = (entries: ReadonlyArray<ChatEntry>, call: ToolCallLike): ReadonlyArray<ChatEntry> => {
|
|
326
|
+
const index = entries.findIndex((entry) => entry._tag === "ToolEntry" && entry.callId === call.id)
|
|
327
|
+
const previous = index >= 0 ? entries[index] : undefined
|
|
328
|
+
const next = ToolEntry({
|
|
329
|
+
callId: call.id,
|
|
330
|
+
name: call.name,
|
|
331
|
+
params: call.params,
|
|
332
|
+
outcome: previous?._tag === "ToolEntry" ? previous.outcome : Pending(),
|
|
333
|
+
progress: previous?._tag === "ToolEntry" ? previous.progress : [],
|
|
334
|
+
})
|
|
335
|
+
if (index < 0) return [...entries, next]
|
|
336
|
+
return entries.map((entry, entryIndex) => (entryIndex === index ? next : entry))
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const resolveTool = (entries: ReadonlyArray<ChatEntry>, result: ToolResultLike): ReadonlyArray<ChatEntry> => {
|
|
340
|
+
const withCall = upsertToolCall(entries, { type: "tool-call", id: result.id, name: result.name, params: undefined })
|
|
341
|
+
return withCall.map((entry) =>
|
|
342
|
+
entry._tag === "ToolEntry" && entry.callId === result.id
|
|
343
|
+
? ToolEntry({
|
|
344
|
+
callId: entry.callId,
|
|
345
|
+
name: entry.name,
|
|
346
|
+
params: entry.params,
|
|
347
|
+
outcome: Completed({ isFailure: result.isFailure, result: result.result }),
|
|
348
|
+
progress: entry.progress,
|
|
349
|
+
})
|
|
350
|
+
: entry,
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const addProgress = (entries: ReadonlyArray<ChatEntry>, callId: string, message: string): ReadonlyArray<ChatEntry> =>
|
|
355
|
+
entries.map((entry) =>
|
|
356
|
+
entry._tag === "ToolEntry" && entry.callId === callId
|
|
357
|
+
? ToolEntry({
|
|
358
|
+
callId: entry.callId,
|
|
359
|
+
name: entry.name,
|
|
360
|
+
params: entry.params,
|
|
361
|
+
outcome: entry.outcome,
|
|
362
|
+
progress: entry.progress.concat(message),
|
|
363
|
+
})
|
|
364
|
+
: entry,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
const failureMessage = (failure: Wire.RunFailure): string => {
|
|
368
|
+
switch (failure._tag) {
|
|
369
|
+
case "@batonfx/core/AgentError":
|
|
370
|
+
return failure.message
|
|
371
|
+
case "@batonfx/core/MiddlewareViolation":
|
|
372
|
+
return failure.detail
|
|
373
|
+
case "@batonfx/core/TurnLimitExceeded":
|
|
374
|
+
return `Turn limit exceeded at turn ${failure.turn}`
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const applyPart = (model: Model, turn: number, part: unknown): Model => {
|
|
379
|
+
if (!isRecord(part) || typeof part.type !== "string") return model
|
|
380
|
+
switch (part.type) {
|
|
381
|
+
case "text-delta":
|
|
382
|
+
return typeof part.delta === "string" ? appendStreaming(model, turn, "text", part.delta) : model
|
|
383
|
+
case "reasoning-delta":
|
|
384
|
+
return typeof part.delta === "string" ? appendStreaming(model, turn, "reasoning", part.delta) : model
|
|
385
|
+
case "tool-call":
|
|
386
|
+
return isToolCall(part) ? { ...model, entries: upsertToolCall(model.entries, part) } : model
|
|
387
|
+
case "tool-result":
|
|
388
|
+
return isToolResult(part) ? { ...model, entries: resolveTool(model.entries, part) } : model
|
|
389
|
+
default:
|
|
390
|
+
return model
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
type Suspension = Extract<Wire.ServerFrameType, { readonly _tag: "Suspended" }>["suspension"]
|
|
395
|
+
|
|
396
|
+
const applySuspension = (model: Model, suspension: Suspension) => {
|
|
397
|
+
if (suspension.reason === "approval") {
|
|
398
|
+
return [
|
|
399
|
+
{
|
|
400
|
+
...model,
|
|
401
|
+
run: AwaitingApproval({
|
|
402
|
+
token: suspension.token,
|
|
403
|
+
toolName: suspension.tool_name,
|
|
404
|
+
params: suspension.tool_params,
|
|
405
|
+
}),
|
|
406
|
+
},
|
|
407
|
+
Option.some(ApprovalRequired()),
|
|
408
|
+
] as const
|
|
409
|
+
}
|
|
410
|
+
const message = `Tool wait suspension for ${suspension.tool_name} is not resolvable by the FoldKit adapter`
|
|
411
|
+
return [{ ...model, run: Failed({ message }) }, Option.some(RunFailed({ message }))] as const
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const applyStatus = (model: Model, status: Wire.SessionStatus): readonly [Model, Option.Option<OutMessage>] => {
|
|
415
|
+
switch (status._tag) {
|
|
416
|
+
case "Idle":
|
|
417
|
+
return [{ ...model, run: Idle() }, Option.none()]
|
|
418
|
+
case "Running":
|
|
419
|
+
return [{ ...model, run: Running({ turn: status.turn }) }, Option.none()]
|
|
420
|
+
case "Suspended":
|
|
421
|
+
return applySuspension(model, status.suspension)
|
|
422
|
+
case "Failed": {
|
|
423
|
+
const message = failureMessage(status.error)
|
|
424
|
+
return [{ ...model, run: Failed({ message }) }, Option.some(RunFailed({ message }))]
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const applyEvent = (model: Model, event: Wire.EventType): readonly [Model, Option.Option<OutMessage>] => {
|
|
430
|
+
switch (event._tag) {
|
|
431
|
+
case "TurnStarted":
|
|
432
|
+
return [
|
|
433
|
+
{ ...model, run: Running({ turn: event.turn }), streaming: { turn: event.turn, text: "", reasoning: "" } },
|
|
434
|
+
Option.none(),
|
|
435
|
+
]
|
|
436
|
+
case "ModelPart":
|
|
437
|
+
return [applyPart(model, event.turn, event.part), Option.none()]
|
|
438
|
+
case "ToolExecutionStarted":
|
|
439
|
+
case "ApprovalRequested":
|
|
440
|
+
return [{ ...model, entries: upsertToolCall(model.entries, event.call) }, Option.none()]
|
|
441
|
+
case "ToolProgress":
|
|
442
|
+
return event.message === undefined
|
|
443
|
+
? [model, Option.none()]
|
|
444
|
+
: [{ ...model, entries: addProgress(model.entries, event.toolCallId, event.message) }, Option.none()]
|
|
445
|
+
case "ToolExecutionCompleted":
|
|
446
|
+
return [
|
|
447
|
+
{ ...model, entries: resolveTool(upsertToolCall(model.entries, event.call), event.result) },
|
|
448
|
+
Option.none(),
|
|
449
|
+
]
|
|
450
|
+
case "TurnCompleted":
|
|
451
|
+
return [flushStreaming(model), Option.none()]
|
|
452
|
+
case "StructuredOutput":
|
|
453
|
+
return [model, Option.none()]
|
|
454
|
+
case "Completed": {
|
|
455
|
+
const flushed = flushStreaming(model)
|
|
456
|
+
return [{ ...flushed, run: Idle() }, Option.some(RunCompleted({ text: event.text }))]
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const textFromContent = (content: ReadonlyArray<unknown>): string =>
|
|
462
|
+
content
|
|
463
|
+
.filter((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
|
|
464
|
+
.map((part) => (part as { readonly text: string }).text)
|
|
465
|
+
.join("")
|
|
466
|
+
|
|
467
|
+
const reasoningFromContent = (content: ReadonlyArray<unknown>): string | null => {
|
|
468
|
+
const reasoning = content
|
|
469
|
+
.filter((part) => isRecord(part) && part.type === "reasoning" && typeof part.text === "string")
|
|
470
|
+
.map((part) => (part as { readonly text: string }).text)
|
|
471
|
+
.join("")
|
|
472
|
+
return reasoning.length === 0 ? null : reasoning
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const projectPrompt = (prompt: Ai.Prompt.Prompt): ReadonlyArray<ChatEntry> => {
|
|
476
|
+
let entries: ReadonlyArray<ChatEntry> = []
|
|
477
|
+
for (const message of prompt.content) {
|
|
478
|
+
if (message.role === "user") {
|
|
479
|
+
const text = textFromContent(message.content)
|
|
480
|
+
if (text.length > 0) entries = entries.concat(UserEntry({ text }))
|
|
481
|
+
} else if (message.role === "assistant") {
|
|
482
|
+
const text = textFromContent(message.content)
|
|
483
|
+
const reasoning = reasoningFromContent(message.content)
|
|
484
|
+
if (text.length > 0 || reasoning !== null) entries = entries.concat(AssistantEntry({ text, reasoning }))
|
|
485
|
+
for (const part of message.content) {
|
|
486
|
+
if (isToolCall(part)) entries = upsertToolCall(entries, part)
|
|
487
|
+
if (isToolResult(part)) entries = resolveTool(entries, part)
|
|
488
|
+
}
|
|
489
|
+
} else if (message.role === "tool") {
|
|
490
|
+
for (const part of message.content) {
|
|
491
|
+
if (isToolResult(part)) entries = resolveTool(entries, part)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return entries
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const applyFrame = (model: Model, frame: Wire.LooseServerFrameType): readonly [Model, Option.Option<OutMessage>] => {
|
|
499
|
+
if (frame.seq <= model.lastSeq) return [model, Option.none()]
|
|
500
|
+
const withSeq = { ...model, lastSeq: frame.seq }
|
|
501
|
+
switch (frame._tag) {
|
|
502
|
+
case "Event":
|
|
503
|
+
return applyEvent(withSeq, frame.event)
|
|
504
|
+
case "Suspended":
|
|
505
|
+
return applySuspension(withSeq, frame.suspension)
|
|
506
|
+
case "Failed": {
|
|
507
|
+
const message = failureMessage(frame.error)
|
|
508
|
+
return [{ ...withSeq, run: Failed({ message }) }, Option.some(RunFailed({ message }))]
|
|
509
|
+
}
|
|
510
|
+
case "Ended":
|
|
511
|
+
return [withSeq, Option.none()]
|
|
512
|
+
case "Snapshot":
|
|
513
|
+
return [{ ...withSeq, entries: projectPrompt(frame.transcript), streaming: null }, Option.none()]
|
|
514
|
+
case "SessionStatus":
|
|
515
|
+
return applyStatus(withSeq, frame.status)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const isServerFrame = (incoming: Connection.Incoming): incoming is Wire.LooseServerFrameType =>
|
|
520
|
+
incoming._tag === "Event" ||
|
|
521
|
+
incoming._tag === "Suspended" ||
|
|
522
|
+
incoming._tag === "Failed" ||
|
|
523
|
+
incoming._tag === "Ended" ||
|
|
524
|
+
incoming._tag === "Snapshot" ||
|
|
525
|
+
incoming._tag === "SessionStatus"
|
|
526
|
+
|
|
527
|
+
/** @experimental */
|
|
528
|
+
export const update = (
|
|
529
|
+
model: Model,
|
|
530
|
+
message: Message,
|
|
531
|
+
): readonly [Model, ReadonlyArray<ChatCommand>, Option.Option<OutMessage>] => {
|
|
532
|
+
switch (message._tag) {
|
|
533
|
+
case "ReceivedAgent":
|
|
534
|
+
if (isServerFrame(message.incoming)) {
|
|
535
|
+
const [next, out] = applyFrame(model, message.incoming)
|
|
536
|
+
return [next, [], out]
|
|
537
|
+
}
|
|
538
|
+
switch (message.incoming._tag) {
|
|
539
|
+
case "ConnectionOpened":
|
|
540
|
+
return [{ ...model, connection: "open" }, [], Option.none()]
|
|
541
|
+
case "ConnectionLost":
|
|
542
|
+
return [
|
|
543
|
+
{ ...model, connection: model.sessionId === null ? "disconnected" : "reconnecting" },
|
|
544
|
+
[],
|
|
545
|
+
Option.none(),
|
|
546
|
+
]
|
|
547
|
+
case "ConnectionFailed":
|
|
548
|
+
return [
|
|
549
|
+
{ ...model, connection: "disconnected", run: Failed({ message: message.incoming.reason }) },
|
|
550
|
+
[],
|
|
551
|
+
Option.some(RunFailed({ message: message.incoming.reason })),
|
|
552
|
+
]
|
|
553
|
+
}
|
|
554
|
+
case "ChangedDraft":
|
|
555
|
+
return [{ ...model, draft: message.text }, [], Option.none()]
|
|
556
|
+
case "SubmittedMessage": {
|
|
557
|
+
const text = model.draft.trim()
|
|
558
|
+
if (model.sessionId === null || text.length === 0) return [model, [], Option.none()]
|
|
559
|
+
return [
|
|
560
|
+
{ ...model, draft: "", entries: [...model.entries, UserEntry({ text })] },
|
|
561
|
+
[SendUserMessage({ sessionId: model.sessionId, text })],
|
|
562
|
+
Option.none(),
|
|
563
|
+
]
|
|
564
|
+
}
|
|
565
|
+
case "ClickedApprove":
|
|
566
|
+
return model.sessionId !== null && model.run._tag === "AwaitingApproval"
|
|
567
|
+
? [
|
|
568
|
+
model,
|
|
569
|
+
[
|
|
570
|
+
ResolveApproval({
|
|
571
|
+
sessionId: model.sessionId,
|
|
572
|
+
token: model.run.token,
|
|
573
|
+
approved: true,
|
|
574
|
+
reason: null,
|
|
575
|
+
}),
|
|
576
|
+
],
|
|
577
|
+
Option.none(),
|
|
578
|
+
]
|
|
579
|
+
: [model, [], Option.none()]
|
|
580
|
+
case "ClickedDeny":
|
|
581
|
+
return model.sessionId !== null && model.run._tag === "AwaitingApproval"
|
|
582
|
+
? [
|
|
583
|
+
model,
|
|
584
|
+
[
|
|
585
|
+
ResolveApproval({
|
|
586
|
+
sessionId: model.sessionId,
|
|
587
|
+
token: model.run.token,
|
|
588
|
+
approved: false,
|
|
589
|
+
reason: message.reason,
|
|
590
|
+
}),
|
|
591
|
+
],
|
|
592
|
+
Option.none(),
|
|
593
|
+
]
|
|
594
|
+
: [model, [], Option.none()]
|
|
595
|
+
case "ClickedCancel":
|
|
596
|
+
return model.sessionId === null
|
|
597
|
+
? [model, [], Option.none()]
|
|
598
|
+
: [model, [CancelRun({ sessionId: model.sessionId })], Option.none()]
|
|
599
|
+
case "FailedAgentCommand":
|
|
600
|
+
return [{ ...model, run: Failed({ message: message.reason }) }, [], Option.none()]
|
|
601
|
+
case "SentUserMessage":
|
|
602
|
+
case "ResolvedApproval":
|
|
603
|
+
case "CancelledRun":
|
|
604
|
+
return [model, [], Option.none()]
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** @experimental */
|
|
609
|
+
export const subscriptions = Subscription.make<Model, Message, Connection.AgentConnection>()((entry) => ({
|
|
610
|
+
agentFrames: entry(
|
|
611
|
+
{ sessionId: Schema.NullOr(Schema.String), afterSeq: Schema.Number },
|
|
612
|
+
{
|
|
613
|
+
modelToDependencies: (model) => ({ sessionId: model.sessionId, afterSeq: model.lastSeq }),
|
|
614
|
+
keepAliveEquivalence: Equivalence.make((left, right) => left.sessionId === right.sessionId),
|
|
615
|
+
dependenciesToStream: ({ sessionId }, readDependencies) => {
|
|
616
|
+
if (sessionId === null) return Stream.empty
|
|
617
|
+
return Stream.unwrap(
|
|
618
|
+
Connection.AgentConnection.use((connection) => {
|
|
619
|
+
const afterSeq = readDependencies().afterSeq
|
|
620
|
+
return Effect.succeed(
|
|
621
|
+
connection
|
|
622
|
+
.frames(afterSeq < 0 ? { sessionId } : { sessionId, afterSeq })
|
|
623
|
+
.pipe(Stream.map((incoming) => ReceivedAgent({ incoming }))),
|
|
624
|
+
)
|
|
625
|
+
}),
|
|
626
|
+
)
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
),
|
|
630
|
+
}))
|