effect-assistant 0.0.0-stage → 0.2.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 +22 -2
- package/dist/AppEvent.d.ts +111 -0
- package/dist/AppEvent.d.ts.map +1 -0
- package/dist/AppEvent.js +71 -0
- package/dist/AppEvent.js.map +1 -0
- package/dist/Assistant.d.ts +403 -0
- package/dist/Assistant.d.ts.map +1 -0
- package/dist/Assistant.js +126 -0
- package/dist/Assistant.js.map +1 -0
- package/dist/AssistantActivity.d.ts +85 -0
- package/dist/AssistantActivity.d.ts.map +1 -0
- package/dist/AssistantActivity.js +105 -0
- package/dist/AssistantActivity.js.map +1 -0
- package/dist/AssistantEngine.d.ts +116 -0
- package/dist/AssistantEngine.d.ts.map +1 -0
- package/dist/AssistantEngine.js +255 -0
- package/dist/AssistantEngine.js.map +1 -0
- package/dist/AssistantEvent.d.ts +551 -0
- package/dist/AssistantEvent.d.ts.map +1 -0
- package/dist/AssistantEvent.js +473 -0
- package/dist/AssistantEvent.js.map +1 -0
- package/dist/AssistantLoop.d.ts +173 -0
- package/dist/AssistantLoop.d.ts.map +1 -0
- package/dist/AssistantLoop.js +111 -0
- package/dist/AssistantLoop.js.map +1 -0
- package/dist/AssistantToolCall.d.ts +32 -0
- package/dist/AssistantToolCall.d.ts.map +1 -0
- package/dist/AssistantToolCall.js +37 -0
- package/dist/AssistantToolCall.js.map +1 -0
- package/dist/Block.d.ts +69 -0
- package/dist/Block.d.ts.map +1 -0
- package/dist/Block.js +39 -0
- package/dist/Block.js.map +1 -0
- package/dist/BlockKit.d.ts +180 -0
- package/dist/BlockKit.d.ts.map +1 -0
- package/dist/BlockKit.js +148 -0
- package/dist/BlockKit.js.map +1 -0
- package/dist/ChatId.d.ts +31 -0
- package/dist/ChatId.d.ts.map +1 -0
- package/dist/ChatId.js +36 -0
- package/dist/ChatId.js.map +1 -0
- package/dist/ChatLog.d.ts +109 -0
- package/dist/ChatLog.d.ts.map +1 -0
- package/dist/ChatLog.js +177 -0
- package/dist/ChatLog.js.map +1 -0
- package/dist/ChatView.d.ts +408 -0
- package/dist/ChatView.d.ts.map +1 -0
- package/dist/ChatView.js +443 -0
- package/dist/ChatView.js.map +1 -0
- package/dist/Chats.d.ts +110 -0
- package/dist/Chats.d.ts.map +1 -0
- package/dist/Chats.js +98 -0
- package/dist/Chats.js.map +1 -0
- package/dist/Compaction.d.ts +105 -0
- package/dist/Compaction.d.ts.map +1 -0
- package/dist/Compaction.js +126 -0
- package/dist/Compaction.js.map +1 -0
- package/dist/CurrentChat.d.ts +40 -0
- package/dist/CurrentChat.d.ts.map +1 -0
- package/dist/CurrentChat.js +33 -0
- package/dist/CurrentChat.js.map +1 -0
- package/dist/InputId.d.ts +38 -0
- package/dist/InputId.d.ts.map +1 -0
- package/dist/InputId.js +43 -0
- package/dist/InputId.js.map +1 -0
- package/dist/Instructions.d.ts +56 -0
- package/dist/Instructions.d.ts.map +1 -0
- package/dist/Instructions.js +50 -0
- package/dist/Instructions.js.map +1 -0
- package/dist/Memory.d.ts +190 -0
- package/dist/Memory.d.ts.map +1 -0
- package/dist/Memory.js +216 -0
- package/dist/Memory.js.map +1 -0
- package/dist/RunId.d.ts +41 -0
- package/dist/RunId.d.ts.map +1 -0
- package/dist/RunId.js +46 -0
- package/dist/RunId.js.map +1 -0
- package/dist/Skill.d.ts +72 -0
- package/dist/Skill.d.ts.map +1 -0
- package/dist/Skill.js +58 -0
- package/dist/Skill.js.map +1 -0
- package/dist/Specialist.d.ts +267 -0
- package/dist/Specialist.d.ts.map +1 -0
- package/dist/Specialist.js +119 -0
- package/dist/Specialist.js.map +1 -0
- package/dist/ToolDiscovery.d.ts +107 -0
- package/dist/ToolDiscovery.d.ts.map +1 -0
- package/dist/ToolDiscovery.js +140 -0
- package/dist/ToolDiscovery.js.map +1 -0
- package/dist/ToolFailure.d.ts +66 -0
- package/dist/ToolFailure.d.ts.map +1 -0
- package/dist/ToolFailure.js +79 -0
- package/dist/ToolFailure.js.map +1 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/internal/chat.js +177 -0
- package/dist/internal/chat.js.map +1 -0
- package/dist/internal/events.d.ts +46 -0
- package/dist/internal/events.d.ts.map +1 -0
- package/dist/internal/events.js +65 -0
- package/dist/internal/events.js.map +1 -0
- package/dist/internal/history.js +147 -0
- package/dist/internal/history.js.map +1 -0
- package/dist/internal/instructions.js +11 -0
- package/dist/internal/instructions.js.map +1 -0
- package/dist/internal/loop.d.ts +73 -0
- package/dist/internal/loop.d.ts.map +1 -0
- package/dist/internal/loop.js +131 -0
- package/dist/internal/loop.js.map +1 -0
- package/dist/internal/schema.js +14 -0
- package/dist/internal/schema.js.map +1 -0
- package/dist/internal/skills.js +28 -0
- package/dist/internal/skills.js.map +1 -0
- package/dist/internal/specialists.js +272 -0
- package/dist/internal/specialists.js.map +1 -0
- package/dist/internal/telemetry.js +85 -0
- package/dist/internal/telemetry.js.map +1 -0
- package/dist/internal/toolkit.d.ts +62 -0
- package/dist/internal/toolkit.d.ts.map +1 -0
- package/dist/internal/toolkit.js +108 -0
- package/dist/internal/toolkit.js.map +1 -0
- package/dist/internal/tools.js +113 -0
- package/dist/internal/tools.js.map +1 -0
- package/dist/internal/turns.d.ts +64 -0
- package/dist/internal/turns.d.ts.map +1 -0
- package/dist/internal/turns.js +214 -0
- package/dist/internal/turns.js.map +1 -0
- package/dist/internal/uuid.js +45 -0
- package/dist/internal/uuid.js.map +1 -0
- package/dist/internal/writer.d.ts +12 -0
- package/dist/internal/writer.d.ts.map +1 -0
- package/package.json +52 -3
- package/src/AppEvent.ts +134 -0
- package/src/Assistant.ts +589 -0
- package/src/AssistantActivity.ts +150 -0
- package/src/AssistantEngine.ts +343 -0
- package/src/AssistantEvent.ts +569 -0
- package/src/AssistantLoop.ts +209 -0
- package/src/AssistantToolCall.ts +40 -0
- package/src/Block.ts +84 -0
- package/src/BlockKit.ts +298 -0
- package/src/ChatId.ts +36 -0
- package/src/ChatLog.ts +247 -0
- package/src/ChatView.ts +587 -0
- package/src/Chats.ts +122 -0
- package/src/Compaction.ts +207 -0
- package/src/CurrentChat.ts +47 -0
- package/src/InputId.ts +43 -0
- package/src/Instructions.ts +72 -0
- package/src/Memory.ts +276 -0
- package/src/RunId.ts +46 -0
- package/src/Skill.ts +91 -0
- package/src/Specialist.ts +355 -0
- package/src/ToolDiscovery.ts +179 -0
- package/src/ToolFailure.ts +74 -0
- package/src/index.ts +120 -0
- package/src/internal/chat.ts +323 -0
- package/src/internal/events.ts +100 -0
- package/src/internal/history.ts +294 -0
- package/src/internal/instructions.ts +9 -0
- package/src/internal/loop.ts +274 -0
- package/src/internal/schema.ts +25 -0
- package/src/internal/skills.ts +37 -0
- package/src/internal/specialists.ts +410 -0
- package/src/internal/telemetry.ts +125 -0
- package/src/internal/toolkit.ts +188 -0
- package/src/internal/tools.ts +159 -0
- package/src/internal/turns.ts +410 -0
- package/src/internal/uuid.ts +50 -0
- package/src/internal/writer.ts +12 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import type * as LanguageModel from "effect/ai/LanguageModel"
|
|
2
|
+
import * as Prompt from "effect/ai/Prompt"
|
|
3
|
+
import * as Tool from "effect/ai/Tool"
|
|
4
|
+
import * as Toolkit from "effect/ai/Toolkit"
|
|
5
|
+
import * as Cause from "effect/Cause"
|
|
6
|
+
import * as Context from "effect/Context"
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import * as Exit from "effect/Exit"
|
|
9
|
+
import * as Fiber from "effect/Fiber"
|
|
10
|
+
import * as FiberMap from "effect/FiberMap"
|
|
11
|
+
import * as FiberSet from "effect/FiberSet"
|
|
12
|
+
import * as Option from "effect/Option"
|
|
13
|
+
import * as Predicate from "effect/Predicate"
|
|
14
|
+
import * as Queue from "effect/Queue"
|
|
15
|
+
import * as Ref from "effect/Ref"
|
|
16
|
+
import * as Schema from "effect/Schema"
|
|
17
|
+
import * as Semaphore from "effect/Semaphore"
|
|
18
|
+
import * as Stream from "effect/Stream"
|
|
19
|
+
import * as AssistantEvent from "../AssistantEvent.ts"
|
|
20
|
+
import type * as AssistantLoop from "../AssistantLoop.ts"
|
|
21
|
+
import * as Loop from "./loop.ts"
|
|
22
|
+
import { CurrentChat } from "../CurrentChat.ts"
|
|
23
|
+
import * as InputId from "../InputId.ts"
|
|
24
|
+
import * as RunId from "../RunId.ts"
|
|
25
|
+
import * as Skill from "../Skill.ts"
|
|
26
|
+
import * as Skills from "./skills.ts"
|
|
27
|
+
import * as Specialist from "../Specialist.ts"
|
|
28
|
+
import * as ToolFailure from "../ToolFailure.ts"
|
|
29
|
+
import { toolMessage, userText } from "./history.ts"
|
|
30
|
+
import { mapText } from "./instructions.ts"
|
|
31
|
+
import * as Telemetry from "./telemetry.ts"
|
|
32
|
+
import * as Toolkits from "./toolkit.ts"
|
|
33
|
+
import type { Writer } from "./writer.ts"
|
|
34
|
+
|
|
35
|
+
/** @internal */
|
|
36
|
+
export type RunHandler = (run: Specialist.Run) => Stream.Stream<AssistantLoop.Event, unknown, Specialist.RunServices>
|
|
37
|
+
|
|
38
|
+
/** @internal */
|
|
39
|
+
export const briefText = (brief: Specialist.Brief): string =>
|
|
40
|
+
[
|
|
41
|
+
`Goal: ${brief.goal}`,
|
|
42
|
+
brief.context && `Context: ${brief.context}`,
|
|
43
|
+
brief.successCriteria && `Done when: ${brief.successCriteria}`,
|
|
44
|
+
brief.report && `Report back: ${brief.report}`,
|
|
45
|
+
brief.limits && `Limits: ${brief.limits}`
|
|
46
|
+
]
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.join("\n")
|
|
49
|
+
|
|
50
|
+
const finalText = (output: AssistantEvent.ModelOutput): string =>
|
|
51
|
+
output.parts
|
|
52
|
+
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
|
53
|
+
.join("")
|
|
54
|
+
.trim()
|
|
55
|
+
|
|
56
|
+
// The answer is the last text the model wrote.
|
|
57
|
+
/** @internal */
|
|
58
|
+
export const runBrief = Effect.fnUntraced(
|
|
59
|
+
function* (
|
|
60
|
+
handler: RunHandler,
|
|
61
|
+
options: {
|
|
62
|
+
readonly specialist: Specialist.Any
|
|
63
|
+
readonly runId: RunId.RunId
|
|
64
|
+
readonly brief: Specialist.Brief
|
|
65
|
+
readonly history: Ref.Ref<ReadonlyArray<Prompt.Message>>
|
|
66
|
+
readonly inbox: Queue.Queue<Prompt.Message>
|
|
67
|
+
readonly chatId: Writer["chatId"]
|
|
68
|
+
}
|
|
69
|
+
) {
|
|
70
|
+
const { history, inbox } = options
|
|
71
|
+
const answer = yield* Ref.make("")
|
|
72
|
+
const add = (messages: ReadonlyArray<Prompt.Message>) => Ref.update(history, (all) => [...all, ...messages])
|
|
73
|
+
|
|
74
|
+
// Read before every model call, when no tool calls are open: messages sent meanwhile join it now.
|
|
75
|
+
const current = Effect.gen(function* () {
|
|
76
|
+
yield* add(yield* Queue.clear(inbox))
|
|
77
|
+
|
|
78
|
+
return yield* Ref.get(history)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const run: Specialist.Run = {
|
|
82
|
+
runId: options.runId,
|
|
83
|
+
chatId: options.chatId,
|
|
84
|
+
brief: options.brief,
|
|
85
|
+
history: current,
|
|
86
|
+
loop: (loop) => {
|
|
87
|
+
const skills = loop.skills ?? []
|
|
88
|
+
|
|
89
|
+
return Loop.run({
|
|
90
|
+
instructions: mapText(loop.instructions, (text) => Skill.withCatalog(text, skills)),
|
|
91
|
+
toolkit: Toolkits.merge(Skills.toTools(skills), Toolkits.fromToolkitOrEmpty(loop.toolkit)),
|
|
92
|
+
history: current,
|
|
93
|
+
maxSteps: loop.maxSteps ?? 8
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A run lives in memory, so there's nothing to run again after a crash: `ToolStarted` isn't kept.
|
|
99
|
+
const record = Effect.fnUntraced(function* (event: AssistantLoop.Event) {
|
|
100
|
+
if (Predicate.isTagged(event, "ToolStarted")) return
|
|
101
|
+
|
|
102
|
+
if (Predicate.isTagged(event, "ToolResult")) return yield* add([toolMessage(event)])
|
|
103
|
+
|
|
104
|
+
yield* add(Prompt.fromResponseParts(event.parts).content)
|
|
105
|
+
const text = finalText(event)
|
|
106
|
+
|
|
107
|
+
if (text.length > 0) yield* Ref.set(answer, text)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
yield* Stream.runForEach(handler(run), record)
|
|
111
|
+
|
|
112
|
+
const text = yield* Ref.get(answer)
|
|
113
|
+
|
|
114
|
+
yield* Telemetry.agentOutput(text)
|
|
115
|
+
|
|
116
|
+
return text
|
|
117
|
+
},
|
|
118
|
+
(effect, _handler, options) => effect.pipe(Telemetry.agentSpan(options.specialist.name, { chatId: options.chatId }))
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
type Outcome =
|
|
122
|
+
| { readonly status: "answered"; readonly report: string }
|
|
123
|
+
| { readonly status: "failed"; readonly error: string }
|
|
124
|
+
| { readonly status: "stopped" | "cut_off" }
|
|
125
|
+
|
|
126
|
+
// Only `stop` interrupts a brief's loop alone; a failure or a defect failed it.
|
|
127
|
+
const unanswered = (cause: Cause.Cause<unknown>): Outcome =>
|
|
128
|
+
Cause.hasInterruptsOnly(cause) ? { status: "stopped" } : { status: "failed", error: String(Cause.squash(cause)) }
|
|
129
|
+
|
|
130
|
+
// The id is fixed per run and brief, so a replay is stored once.
|
|
131
|
+
/** @internal */
|
|
132
|
+
export const finished = (
|
|
133
|
+
specialist: string,
|
|
134
|
+
run: { readonly runId: RunId.RunId; readonly brief: number; readonly goal: string },
|
|
135
|
+
outcome: Outcome
|
|
136
|
+
): AssistantEvent.SpecialistFinished =>
|
|
137
|
+
AssistantEvent.SpecialistFinished.make({
|
|
138
|
+
id: InputId.fromKey(`${run.runId}:${run.brief}`),
|
|
139
|
+
runId: run.runId,
|
|
140
|
+
specialist,
|
|
141
|
+
brief: run.brief,
|
|
142
|
+
goal: run.goal,
|
|
143
|
+
...outcome
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const RunIdInput = RunId.RunId.annotate({ description: "The runId spawn_specialist returned." })
|
|
147
|
+
|
|
148
|
+
const RunStatus = <const Status extends string>(status: ReadonlyArray<Status>) =>
|
|
149
|
+
Schema.Struct({ runId: RunId.RunId, status: Schema.Literals(status) })
|
|
150
|
+
|
|
151
|
+
const MessageSpecialist = Tool.make("message_specialist", {
|
|
152
|
+
description:
|
|
153
|
+
"Send more to one of your specialists: new instructions for a running one (it reads them at its next step), or a follow-up for a finished one (it continues with what it already found).",
|
|
154
|
+
parameters: Schema.Struct({ runId: RunIdInput, message: Schema.String }),
|
|
155
|
+
success: RunStatus(["steered", "continued"]),
|
|
156
|
+
failure: ToolFailure.ToolFailure,
|
|
157
|
+
dependencies: [Specialist.Runner]
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const StopSpecialist = Tool.make("stop_specialist", {
|
|
161
|
+
description:
|
|
162
|
+
"Stop one of your running specialists, for example when the user changed their mind. It posts no answer.",
|
|
163
|
+
parameters: Schema.Struct({ runId: RunIdInput }),
|
|
164
|
+
success: RunStatus(["stopped"]),
|
|
165
|
+
failure: ToolFailure.ToolFailure,
|
|
166
|
+
dependencies: [Specialist.Runner]
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const refused = <A, R>(effect: Effect.Effect<A, Specialist.SpecialistError, R>) =>
|
|
170
|
+
Effect.catch(effect, (error) => ToolFailure.fail(error.message, { reason: error.reason }))
|
|
171
|
+
|
|
172
|
+
/** @internal */
|
|
173
|
+
export const toolkit = Effect.fnUntraced(function* (
|
|
174
|
+
specialists: ReadonlyArray<Specialist.Any>
|
|
175
|
+
): Effect.fn.Return<Toolkits.Tools<Specialist.Runner>> {
|
|
176
|
+
if (specialists.length === 0) return Toolkits.empty
|
|
177
|
+
|
|
178
|
+
const catalog = specialists.map((specialist) => `- ${specialist.name}: ${specialist.description}`).join("\n")
|
|
179
|
+
|
|
180
|
+
const SpawnSpecialist = Tool.make("spawn_specialist", {
|
|
181
|
+
description: `Start a specialist on a job. It works in the background and its report arrives as a <specialist> input, often within seconds. Write a self-contained brief: it can't see this chat. For a quick question, just call wait; for slow work, tell the user in one line first.\nPass one of these as specialist:\n${catalog}`,
|
|
182
|
+
parameters: Schema.Struct({
|
|
183
|
+
specialist: Schema.Literals(specialists.map((specialist) => specialist.name)).annotate({
|
|
184
|
+
description: "The specialist to start, by name."
|
|
185
|
+
}),
|
|
186
|
+
...Specialist.Brief.fields
|
|
187
|
+
}),
|
|
188
|
+
success: RunStatus(["started"]),
|
|
189
|
+
failure: ToolFailure.ToolFailure,
|
|
190
|
+
dependencies: [Specialist.Runner]
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const SpecialistTools = Toolkit.make(SpawnSpecialist, MessageSpecialist, StopSpecialist)
|
|
194
|
+
|
|
195
|
+
const handlers = yield* SpecialistTools.toHandlers({
|
|
196
|
+
spawn_specialist: Effect.fnUntraced(function* ({ specialist, ...brief }) {
|
|
197
|
+
const runId = yield* refused(Specialist.Runner.use((runner) => runner.spawn({ specialist, brief })))
|
|
198
|
+
|
|
199
|
+
return { runId, status: "started" }
|
|
200
|
+
}),
|
|
201
|
+
message_specialist: Effect.fnUntraced(function* ({ runId, message }) {
|
|
202
|
+
const status = yield* refused(Specialist.Runner.use((runner) => runner.message(runId, message)))
|
|
203
|
+
|
|
204
|
+
return { runId, status }
|
|
205
|
+
}),
|
|
206
|
+
stop_specialist: Effect.fnUntraced(function* ({ runId }) {
|
|
207
|
+
yield* refused(Specialist.Runner.use((runner) => runner.stop(runId)))
|
|
208
|
+
|
|
209
|
+
return { runId, status: "stopped" }
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
return Toolkits.fromWithHandler(yield* Effect.provideContext(SpecialistTools, handlers))
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// How long a finished run stays available for follow-ups.
|
|
217
|
+
const warm = "15 minutes"
|
|
218
|
+
|
|
219
|
+
interface Run {
|
|
220
|
+
readonly runId: RunId.RunId
|
|
221
|
+
readonly specialist: Specialist.Any
|
|
222
|
+
readonly handler: RunHandler
|
|
223
|
+
readonly goal: string
|
|
224
|
+
readonly brief: Specialist.Brief
|
|
225
|
+
readonly history: Ref.Ref<ReadonlyArray<Prompt.Message>>
|
|
226
|
+
// Messages sent while it works; the loop reads them at its next step.
|
|
227
|
+
readonly inbox: Queue.Queue<Prompt.Message>
|
|
228
|
+
// Counts follow-ups too; numbers the reports.
|
|
229
|
+
readonly briefs: Ref.Ref<number>
|
|
230
|
+
// Read and changed under `lock`.
|
|
231
|
+
readonly busy: Ref.Ref<boolean>
|
|
232
|
+
// Orders a message against the run finishing, so none is dropped or starts a second loop.
|
|
233
|
+
readonly lock: Semaphore.Semaphore
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** @internal */
|
|
237
|
+
export const makeRunner = Effect.fnUntraced(function* (options: {
|
|
238
|
+
readonly chat: Writer
|
|
239
|
+
readonly handle: CurrentChat["Service"]
|
|
240
|
+
readonly specialists: ReadonlyArray<{
|
|
241
|
+
readonly specialist: Specialist.Any
|
|
242
|
+
readonly handler: RunHandler
|
|
243
|
+
}>
|
|
244
|
+
readonly model: Context.Context<LanguageModel.LanguageModel>
|
|
245
|
+
// An interruption keeps the hold.
|
|
246
|
+
readonly holding: (effect: Effect.Effect<void>) => Effect.Effect<void>
|
|
247
|
+
readonly report: (finished: AssistantEvent.SpecialistFinished) => Effect.Effect<void>
|
|
248
|
+
}) {
|
|
249
|
+
const { chat } = options
|
|
250
|
+
const runs = new Map<string, Run>()
|
|
251
|
+
const byName = new Map(options.specialists.map((entry) => [entry.specialist.name, entry]))
|
|
252
|
+
// The brief loops working now, and the finished runs' countdowns to being forgotten.
|
|
253
|
+
const working = yield* FiberMap.make<string, Outcome>()
|
|
254
|
+
const forgetting = yield* FiberMap.make<string>()
|
|
255
|
+
const background = yield* FiberSet.make()
|
|
256
|
+
|
|
257
|
+
const report = Effect.fnUntraced(function* (run: Run, outcome: Outcome) {
|
|
258
|
+
const brief = yield* Ref.get(run.briefs)
|
|
259
|
+
|
|
260
|
+
yield* options.report(finished(run.specialist.name, { runId: run.runId, brief, goal: run.goal }, outcome))
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
const once = (run: Run) =>
|
|
264
|
+
runBrief(run.handler, {
|
|
265
|
+
specialist: run.specialist,
|
|
266
|
+
runId: run.runId,
|
|
267
|
+
brief: run.brief,
|
|
268
|
+
history: run.history,
|
|
269
|
+
inbox: run.inbox,
|
|
270
|
+
chatId: chat.chatId
|
|
271
|
+
}).pipe(
|
|
272
|
+
Effect.provideContext(Context.merge(options.model, services)),
|
|
273
|
+
// Spawned from a turn, a run would inherit the turn's own tools: a run can't message the user.
|
|
274
|
+
Effect.provideService(Loop.TurnTools, Option.none()),
|
|
275
|
+
Effect.matchCause({
|
|
276
|
+
onSuccess: (answer): Outcome => ({ status: "answered", report: answer }),
|
|
277
|
+
onFailure: unanswered
|
|
278
|
+
})
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
// A message that arrived after the last read: keep working before reporting.
|
|
282
|
+
const untilCaughtUp = Effect.fnUntraced(function* (run: Run) {
|
|
283
|
+
let outcome = yield* once(run)
|
|
284
|
+
|
|
285
|
+
while ((yield* Queue.size(run.inbox)) > 0) outcome = yield* once(run)
|
|
286
|
+
|
|
287
|
+
return outcome
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
const work = Effect.fnUntraced(
|
|
291
|
+
function* (run: Run) {
|
|
292
|
+
yield* chat.append([
|
|
293
|
+
AssistantEvent.SpecialistStarted.make({
|
|
294
|
+
runId: run.runId,
|
|
295
|
+
specialist: run.specialist.name,
|
|
296
|
+
goal: run.goal
|
|
297
|
+
})
|
|
298
|
+
])
|
|
299
|
+
|
|
300
|
+
let finished = false
|
|
301
|
+
|
|
302
|
+
while (!finished) {
|
|
303
|
+
const exit = yield* Fiber.await(yield* FiberMap.run(working, run.runId, untilCaughtUp(run)))
|
|
304
|
+
const outcome = Exit.isSuccess(exit) ? exit.value : unanswered(exit.cause)
|
|
305
|
+
|
|
306
|
+
finished = yield* run.lock.withPermit(
|
|
307
|
+
Effect.gen(function* () {
|
|
308
|
+
// A message steered in after the loop's last read: work on it before reporting.
|
|
309
|
+
if (outcome.status !== "stopped" && (yield* Queue.size(run.inbox)) > 0) return false
|
|
310
|
+
|
|
311
|
+
yield* report(run, outcome)
|
|
312
|
+
yield* Ref.set(run.busy, false)
|
|
313
|
+
yield* FiberMap.run(
|
|
314
|
+
forgetting,
|
|
315
|
+
run.runId,
|
|
316
|
+
Effect.gen(function* () {
|
|
317
|
+
yield* Effect.sleep(warm)
|
|
318
|
+
runs.delete(run.runId)
|
|
319
|
+
})
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return true
|
|
323
|
+
})
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
// A shutdown interrupts the whole run: no turn can start now, so the report waits for the chat's next start.
|
|
328
|
+
(effect, run) => Effect.onInterrupt(effect, () => report(run, { status: "cut_off" }))
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
const launch = Effect.fnUntraced(function* (run: Run) {
|
|
332
|
+
yield* FiberMap.remove(forgetting, run.runId)
|
|
333
|
+
yield* FiberSet.run(background, options.holding(work(run)))
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// Under the run's lock.
|
|
337
|
+
const send = Effect.fnUntraced(function* (run: Run, text: string): Effect.fn.Return<"steered" | "continued"> {
|
|
338
|
+
yield* Ref.update(run.briefs, (n) => n + 1)
|
|
339
|
+
|
|
340
|
+
if (yield* Ref.get(run.busy)) {
|
|
341
|
+
yield* Queue.offer(run.inbox, userText(text))
|
|
342
|
+
|
|
343
|
+
return "steered"
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
yield* Ref.update(run.history, (all) => [...all, userText(text)])
|
|
347
|
+
yield* Ref.set(run.busy, true)
|
|
348
|
+
yield* launch(run)
|
|
349
|
+
|
|
350
|
+
return "continued"
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
const runner = Specialist.Runner.of({
|
|
354
|
+
spawn: Effect.fnUntraced(function* (request: Specialist.SpawnRequest) {
|
|
355
|
+
const found = byName.get(request.specialist)
|
|
356
|
+
|
|
357
|
+
if (found === undefined) {
|
|
358
|
+
return yield* new Specialist.SpecialistError({
|
|
359
|
+
reason: "UnknownSpecialist",
|
|
360
|
+
message: `No specialist named ${request.specialist}.`
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const run: Run = {
|
|
365
|
+
runId: yield* RunId.make(),
|
|
366
|
+
specialist: found.specialist,
|
|
367
|
+
handler: found.handler,
|
|
368
|
+
goal: request.brief.goal,
|
|
369
|
+
brief: request.brief,
|
|
370
|
+
history: yield* Ref.make<ReadonlyArray<Prompt.Message>>([userText(briefText(request.brief))]),
|
|
371
|
+
inbox: yield* Queue.unbounded<Prompt.Message>(),
|
|
372
|
+
briefs: yield* Ref.make(1),
|
|
373
|
+
busy: yield* Ref.make(true),
|
|
374
|
+
lock: yield* Semaphore.make(1)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
runs.set(run.runId, run)
|
|
378
|
+
yield* launch(run)
|
|
379
|
+
|
|
380
|
+
return run.runId
|
|
381
|
+
}),
|
|
382
|
+
message: Effect.fnUntraced(function* (
|
|
383
|
+
runId: RunId.RunId,
|
|
384
|
+
text: string
|
|
385
|
+
): Effect.fn.Return<"steered" | "continued", Specialist.SpecialistError> {
|
|
386
|
+
const run = runs.get(runId)
|
|
387
|
+
|
|
388
|
+
if (run === undefined) {
|
|
389
|
+
return yield* new Specialist.SpecialistError({
|
|
390
|
+
reason: "RunNotFound",
|
|
391
|
+
message: `Run ${runId} is no longer available. Spawn a new one with the full context.`
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return yield* run.lock.withPermit(send(run, text))
|
|
396
|
+
}),
|
|
397
|
+
stop: Effect.fnUntraced(function* (runId: RunId.RunId) {
|
|
398
|
+
if (!(yield* FiberMap.has(working, runId))) {
|
|
399
|
+
return yield* new Specialist.SpecialistError({ reason: "NotRunning", message: `Run ${runId} isn't running.` })
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
yield* FiberMap.remove(working, runId)
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
// What a run's handler gets: its chat, this runner, and (merged in `once`) the model.
|
|
407
|
+
const services = Context.make(CurrentChat, options.handle).pipe(Context.add(Specialist.Runner, runner))
|
|
408
|
+
|
|
409
|
+
return runner
|
|
410
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type * as LanguageModel from "effect/ai/LanguageModel"
|
|
2
|
+
import * as Model from "effect/ai/Model"
|
|
3
|
+
import * as Prompt from "effect/ai/Prompt"
|
|
4
|
+
import * as Telemetry from "effect/ai/Telemetry"
|
|
5
|
+
import type * as Tool from "effect/ai/Tool"
|
|
6
|
+
import * as Effect from "effect/Effect"
|
|
7
|
+
import * as Option from "effect/Option"
|
|
8
|
+
import * as References from "effect/References"
|
|
9
|
+
import * as Schema from "effect/Schema"
|
|
10
|
+
import * as Tracer from "effect/Tracer"
|
|
11
|
+
import * as AssistantEvent from "../AssistantEvent.ts"
|
|
12
|
+
import type { SpanAttributes } from "../Instructions.ts"
|
|
13
|
+
import { toJsonText } from "./schema.ts"
|
|
14
|
+
|
|
15
|
+
type Traced = string | ReadonlyArray<{ readonly _tag: string }>
|
|
16
|
+
|
|
17
|
+
// Spans follow the OpenTelemetry GenAI conventions, so LLM tools (Langfuse, Phoenix, Datadog LLM Observability) can
|
|
18
|
+
// pick the agent work out of a trace.
|
|
19
|
+
/** @internal */
|
|
20
|
+
export const agentSpan = (name: string, options: { readonly chatId: string; readonly root?: boolean }) =>
|
|
21
|
+
Effect.withSpan(`invoke_agent ${name}`, {
|
|
22
|
+
kind: "internal",
|
|
23
|
+
root: options.root ?? false,
|
|
24
|
+
attributes: {
|
|
25
|
+
"gen_ai.operation.name": "invoke_agent",
|
|
26
|
+
"gen_ai.agent.name": name,
|
|
27
|
+
"gen_ai.conversation.id": options.chatId,
|
|
28
|
+
"session.id": options.chatId
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// None when tracing is off, or when the tracer is Effect's own, which exports nothing.
|
|
33
|
+
/** @internal */
|
|
34
|
+
export const currentTraceId: Effect.Effect<Option.Option<string>> = Effect.gen(function* () {
|
|
35
|
+
const tracer = yield* Tracer.Tracer
|
|
36
|
+
|
|
37
|
+
if (!(yield* References.TracerEnabled) || tracer === Tracer.nativeTracer) return Option.none()
|
|
38
|
+
|
|
39
|
+
return Option.map(yield* Effect.option(Effect.currentSpan), (span) => span.traceId)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/** @internal */
|
|
43
|
+
export const agentInput = (input: Traced) => Effect.annotateCurrentSpan("input.value", toJsonText(input))
|
|
44
|
+
|
|
45
|
+
/** @internal */
|
|
46
|
+
export const agentOutput = (output: Traced) => Effect.annotateCurrentSpan("output.value", toJsonText(output))
|
|
47
|
+
|
|
48
|
+
const encodePrompt = Schema.encodeOption(Prompt.Prompt)
|
|
49
|
+
|
|
50
|
+
const encodeParts = Schema.encodeOption(Schema.Array(AssistantEvent.ModelPart))
|
|
51
|
+
|
|
52
|
+
const annotateChat = Effect.fnUntraced(function* <Tools extends Record<string, Tool.Any>>(
|
|
53
|
+
response: LanguageModel.GenerateTextResponse<Tools, "opaque">,
|
|
54
|
+
name: Option.Option<string>
|
|
55
|
+
) {
|
|
56
|
+
const span = yield* Effect.currentSpan
|
|
57
|
+
const { usage } = response
|
|
58
|
+
|
|
59
|
+
Telemetry.addGenAIAnnotations(span, {
|
|
60
|
+
operation: { name: "chat" },
|
|
61
|
+
request: { model: Option.getOrUndefined(name) },
|
|
62
|
+
response: { finishReasons: [response.finishReason] },
|
|
63
|
+
usage: {
|
|
64
|
+
inputTokens: usage.inputTokens.total,
|
|
65
|
+
outputTokens: usage.outputTokens.total
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const completion = encodeParts(
|
|
70
|
+
response.content.flatMap((part) => Option.toArray(Schema.decodeUnknownOption(AssistantEvent.ModelPart)(part)))
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
span.attribute("gen_ai.completion", toJsonText(Option.getOrUndefined(completion)))
|
|
74
|
+
|
|
75
|
+
if (usage.inputTokens.cacheRead !== undefined) {
|
|
76
|
+
span.attribute("gen_ai.usage.cache_read_input_tokens", usage.inputTokens.cacheRead)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (usage.inputTokens.cacheWrite !== undefined) {
|
|
80
|
+
span.attribute("gen_ai.usage.cache_creation_input_tokens", usage.inputTokens.cacheWrite)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Named after the model when the context has its name (`AnthropicLanguageModel.model(...)` provides it).
|
|
85
|
+
/** @internal */
|
|
86
|
+
export const chatSpan =
|
|
87
|
+
(prompt: Prompt.Prompt, attributes: SpanAttributes | undefined) =>
|
|
88
|
+
<Tools extends Record<string, Tool.Any>, E, R>(
|
|
89
|
+
self: Effect.Effect<LanguageModel.GenerateTextResponse<Tools, "opaque">, E, R>
|
|
90
|
+
): Effect.Effect<LanguageModel.GenerateTextResponse<Tools, "opaque">, E, R> =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
const name = yield* Effect.serviceOption(Model.ModelName)
|
|
93
|
+
|
|
94
|
+
return yield* Effect.gen(function* () {
|
|
95
|
+
const response = yield* self
|
|
96
|
+
|
|
97
|
+
yield* annotateChat(response, name).pipe(Effect.ignore)
|
|
98
|
+
|
|
99
|
+
return response
|
|
100
|
+
}).pipe(
|
|
101
|
+
Effect.withSpan(Option.match(name, { onNone: () => "chat", onSome: (model) => `chat ${model}` }), {
|
|
102
|
+
kind: "client",
|
|
103
|
+
attributes: {
|
|
104
|
+
"gen_ai.prompt": toJsonText(Option.map(encodePrompt(prompt), (encoded) => encoded.content)),
|
|
105
|
+
...attributes
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
/** @internal */
|
|
112
|
+
export const toolSpan = (call: AssistantEvent.ToolCallPart) =>
|
|
113
|
+
Effect.withSpan(`execute_tool ${call.name}`, {
|
|
114
|
+
kind: "internal",
|
|
115
|
+
attributes: {
|
|
116
|
+
"gen_ai.operation.name": "execute_tool",
|
|
117
|
+
"gen_ai.tool.name": call.name,
|
|
118
|
+
"gen_ai.tool.call.id": call.id,
|
|
119
|
+
"input.value": toJsonText({ arguments: call.params })
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
/** @internal */
|
|
124
|
+
export const toolOutput = (result: Schema.Json) =>
|
|
125
|
+
Effect.annotateCurrentSpan("output.value", Schema.is(Schema.String)(result) ? result : toJsonText(result))
|