effect-assistant 0.3.0 → 0.4.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/dist/Assistant.d.ts +5 -11
- package/dist/Assistant.d.ts.map +1 -1
- package/dist/Assistant.js.map +1 -1
- package/dist/AssistantEvent.d.ts +55 -7
- package/dist/AssistantEvent.d.ts.map +1 -1
- package/dist/AssistantEvent.js +61 -7
- package/dist/AssistantEvent.js.map +1 -1
- package/dist/AssistantLoop.d.ts +4 -2
- package/dist/AssistantLoop.d.ts.map +1 -1
- package/dist/AssistantLoop.js +4 -2
- package/dist/AssistantLoop.js.map +1 -1
- package/dist/Compaction.d.ts +1 -1
- package/dist/Compaction.d.ts.map +1 -1
- package/dist/Compaction.js +13 -6
- package/dist/Compaction.js.map +1 -1
- package/dist/Skill.d.ts +85 -43
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +93 -44
- package/dist/Skill.js.map +1 -1
- package/dist/Specialist.d.ts +6 -9
- package/dist/Specialist.d.ts.map +1 -1
- package/dist/Specialist.js.map +1 -1
- package/dist/Timeline.d.ts +40 -6
- package/dist/Timeline.d.ts.map +1 -1
- package/dist/Timeline.js +74 -15
- package/dist/Timeline.js.map +1 -1
- package/dist/ToolApproval.d.ts +146 -0
- package/dist/ToolApproval.d.ts.map +1 -0
- package/dist/ToolApproval.js +201 -0
- package/dist/ToolApproval.js.map +1 -0
- package/dist/ToolDiscovery.d.ts +6 -4
- package/dist/ToolDiscovery.d.ts.map +1 -1
- package/dist/ToolDiscovery.js +5 -4
- package/dist/ToolDiscovery.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/internal/chat.js +4 -1
- package/dist/internal/chat.js.map +1 -1
- package/dist/internal/history.js +28 -3
- package/dist/internal/history.js.map +1 -1
- package/dist/internal/loop.d.ts +10 -5
- package/dist/internal/loop.d.ts.map +1 -1
- package/dist/internal/loop.js +54 -21
- package/dist/internal/loop.js.map +1 -1
- package/dist/internal/specialists.js +9 -16
- package/dist/internal/specialists.js.map +1 -1
- package/dist/internal/toolkit.d.ts +5 -7
- package/dist/internal/toolkit.d.ts.map +1 -1
- package/dist/internal/toolkit.js +28 -18
- package/dist/internal/toolkit.js.map +1 -1
- package/dist/internal/turns.d.ts.map +1 -1
- package/dist/internal/turns.js +18 -13
- package/dist/internal/turns.js.map +1 -1
- package/package.json +1 -1
- package/src/Assistant.ts +7 -13
- package/src/AssistantEvent.ts +60 -6
- package/src/AssistantLoop.ts +4 -2
- package/src/Compaction.ts +19 -2
- package/src/Skill.ts +134 -57
- package/src/Specialist.ts +8 -11
- package/src/Timeline.ts +104 -18
- package/src/ToolApproval.ts +249 -0
- package/src/ToolDiscovery.ts +8 -3
- package/src/index.ts +5 -0
- package/src/internal/chat.ts +11 -1
- package/src/internal/history.ts +58 -3
- package/src/internal/loop.ts +120 -34
- package/src/internal/specialists.ts +7 -13
- package/src/internal/toolkit.ts +47 -30
- package/src/internal/turns.ts +24 -7
- package/dist/internal/skills.js +0 -33
- package/dist/internal/skills.js.map +0 -1
- package/src/internal/skills.ts +0 -48
package/src/internal/loop.ts
CHANGED
|
@@ -21,7 +21,18 @@ import * as Toolkits from "./toolkit.ts"
|
|
|
21
21
|
import { isTurnEnding } from "./tools.ts"
|
|
22
22
|
|
|
23
23
|
/** @internal */
|
|
24
|
-
export type Event =
|
|
24
|
+
export type Event =
|
|
25
|
+
| AssistantEvent.ModelOutput
|
|
26
|
+
| AssistantEvent.ToolStarted
|
|
27
|
+
| AssistantEvent.ToolResult
|
|
28
|
+
| AssistantEvent.ApprovalRequested
|
|
29
|
+
|
|
30
|
+
// Only a turn's loop can wait for the user's approval, and runs the calls they approved. A turn's specialists inherit
|
|
31
|
+
// its context, so their runs set it back to none.
|
|
32
|
+
/** @internal */
|
|
33
|
+
export const TurnApprovals = Context.Reference<
|
|
34
|
+
Option.Option<{ readonly approved: Effect.Effect<ReadonlyArray<AssistantEvent.ToolCallPart>> }>
|
|
35
|
+
>("effect-assistant/TurnApprovals", { defaultValue: Option.none })
|
|
25
36
|
|
|
26
37
|
// The assistant's own tools with the turn's services, so an app's own loop in a turn can message the user. A
|
|
27
38
|
// specialist's run has none.
|
|
@@ -61,8 +72,9 @@ export interface StepResult {
|
|
|
61
72
|
/** The model's output; unset when the step only ran calls a crash cut off. */
|
|
62
73
|
readonly output: AssistantEvent.ModelOutput | undefined
|
|
63
74
|
/**
|
|
64
|
-
* Whether the model is done: it made no tool calls,
|
|
65
|
-
* with `wait: true`) and none of the step's
|
|
75
|
+
* Whether the model is done: it made no tool calls, one of its calls waits for the user's
|
|
76
|
+
* approval, or it ended its turn (`wait`, or a message with `wait: true`) and none of the step's
|
|
77
|
+
* calls failed.
|
|
66
78
|
*/
|
|
67
79
|
readonly done: boolean
|
|
68
80
|
/**
|
|
@@ -71,9 +83,15 @@ export interface StepResult {
|
|
|
71
83
|
readonly historyLength: number
|
|
72
84
|
}
|
|
73
85
|
|
|
74
|
-
// The loop runs the calls itself, after storing each as started, so the model call runs none.
|
|
86
|
+
// The loop runs the calls itself, after storing each as started, so the model call runs none. It also decides which
|
|
87
|
+
// calls wait for approval: the model call would ask for approvals of its own.
|
|
75
88
|
const requestToolkit = (definitions: ReadonlyArray<Tool.Any>): Toolkit.WithHandler<Record<string, Tool.Any>> => ({
|
|
76
|
-
tools: Object.fromEntries(
|
|
89
|
+
tools: Object.fromEntries(
|
|
90
|
+
definitions.map((tool) => [
|
|
91
|
+
tool.name,
|
|
92
|
+
tool.needsApproval === undefined || tool.needsApproval === false ? tool : tool.setNeedsApproval(false)
|
|
93
|
+
])
|
|
94
|
+
),
|
|
77
95
|
handle: () => Effect.succeed(Stream.empty)
|
|
78
96
|
})
|
|
79
97
|
|
|
@@ -111,28 +129,79 @@ const runCall = Effect.fnUntraced(function* <R>(tools: Toolkits.Tools<R>, call:
|
|
|
111
129
|
return AssistantEvent.ToolResult.make({ callId: call.id, tool: call.name, ...result })
|
|
112
130
|
})
|
|
113
131
|
|
|
114
|
-
//
|
|
115
|
-
|
|
132
|
+
// A call that needs approval waits for the user in a turn. A specialist's run can't ask them, so its call fails.
|
|
133
|
+
const awaitApproval = Effect.fnUntraced(function* (call: AssistantEvent.ToolCallPart) {
|
|
134
|
+
if (Option.isNone(yield* TurnApprovals)) {
|
|
135
|
+
return AssistantEvent.ToolResult.make({
|
|
136
|
+
callId: call.id,
|
|
137
|
+
tool: call.name,
|
|
138
|
+
...Toolkits.failed(
|
|
139
|
+
`${call.name} needs the user's approval, and only the assistant can ask for it. Don't run it: say in your answer what needs approving, with the details.`,
|
|
140
|
+
"NeedsApproval"
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return AssistantEvent.ApprovalRequested.make({ callId: call.id, tool: call.name, params: call.params })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
interface Pending {
|
|
149
|
+
readonly call: AssistantEvent.ToolCallPart
|
|
150
|
+
/** The user approved it: it runs without asking again. */
|
|
151
|
+
readonly approved: boolean
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface Ran {
|
|
155
|
+
/** One of the calls failed, so the model has to read the results. */
|
|
156
|
+
readonly failed: boolean
|
|
157
|
+
/** One of the calls waits for the user's approval, so the turn ends. */
|
|
158
|
+
readonly waiting: boolean
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Every `ToolStarted` is stored before any call runs; a call that waits for approval doesn't start.
|
|
116
162
|
const runCalls = <R, S, E, R2>(
|
|
117
163
|
tools: Toolkits.Tools<R>,
|
|
118
|
-
|
|
119
|
-
|
|
164
|
+
pending: ReadonlyArray<Pending>,
|
|
165
|
+
history: ReadonlyArray<Prompt.Message>,
|
|
166
|
+
then: (ran: Ran) => Stream.Stream<Step<never, S>, E, R2>
|
|
120
167
|
): Stream.Stream<Step<Event, S>, E, R | R2> =>
|
|
121
|
-
Stream.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
Stream.unwrap(
|
|
126
|
-
Effect.gen(function* () {
|
|
127
|
-
const done = yield* Effect.forEach(calls, (call) => runCall(tools, call), { concurrency: "unbounded" })
|
|
128
|
-
|
|
129
|
-
return Stream.fromIterable(done.map(value)).pipe(Stream.concat(then(done.some(isFailure))))
|
|
130
|
-
})
|
|
168
|
+
Stream.unwrap(
|
|
169
|
+
Effect.gen(function* () {
|
|
170
|
+
const waits = yield* Effect.forEach(pending, ({ call, approved }) =>
|
|
171
|
+
approved ? Effect.succeed(false) : tools.needsApproval(call, history)
|
|
131
172
|
)
|
|
132
|
-
|
|
173
|
+
|
|
174
|
+
const started = pending.flatMap(({ call }, i) =>
|
|
175
|
+
waits[i] === true ? [] : [value(AssistantEvent.ToolStarted.make({ callId: call.id, tool: call.name }))]
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return Stream.fromIterable(started).pipe(
|
|
179
|
+
Stream.concat(
|
|
180
|
+
Stream.unwrap(
|
|
181
|
+
Effect.gen(function* () {
|
|
182
|
+
const done = yield* Effect.forEach(
|
|
183
|
+
pending,
|
|
184
|
+
({ call }, i): Effect.Effect<Event, never, R> =>
|
|
185
|
+
waits[i] === true ? awaitApproval(call) : runCall(tools, call),
|
|
186
|
+
{ concurrency: "unbounded" }
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return Stream.fromIterable(done.map(value)).pipe(
|
|
190
|
+
Stream.concat(
|
|
191
|
+
then({
|
|
192
|
+
failed: done.some(isFailure),
|
|
193
|
+
waiting: done.some((event) => Predicate.isTagged(event, "ApprovalRequested"))
|
|
194
|
+
})
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
})
|
|
133
202
|
)
|
|
134
203
|
|
|
135
|
-
const isFailure = (
|
|
204
|
+
const isFailure = (event: Event) => Predicate.isTagged(event, "ToolResult") && event.outcome === "failure"
|
|
136
205
|
|
|
137
206
|
/** @internal */
|
|
138
207
|
export type Step<A, S> = Data.TaggedEnum<{
|
|
@@ -192,19 +261,31 @@ export const step = <R, S, E2 = never, R2 = never>(
|
|
|
192
261
|
const instructions = Predicate.isString(options.instructions) ? { text: options.instructions } : options.instructions
|
|
193
262
|
|
|
194
263
|
const after = (result: StepResult) => Stream.suspend(() => then(result))
|
|
195
|
-
const needingApproval = Toolkits.needingApproval(toolkit.definitions)
|
|
196
|
-
|
|
197
|
-
if (needingApproval.length > 0) return Stream.die(Toolkits.needsApprovalMessage(needingApproval))
|
|
198
264
|
|
|
199
265
|
return Stream.unwrap(
|
|
200
266
|
Effect.gen(function* () {
|
|
201
267
|
const { messages: history, throughSeq } = readHistory(yield* options.history)
|
|
202
268
|
// Calls a crash cut off, with no result: they run again, with the same ids, before the model goes on.
|
|
203
269
|
const cutOff = History.unansweredCalls(history)
|
|
270
|
+
const approvals = yield* TurnApprovals
|
|
271
|
+
|
|
272
|
+
// Calls the user approved: they run as the model made them, with the same ids, before the model goes on. Not in
|
|
273
|
+
// a loop without their tools.
|
|
274
|
+
const approved = Option.isNone(approvals)
|
|
275
|
+
? []
|
|
276
|
+
: (yield* approvals.value.approved).filter(
|
|
277
|
+
(call) =>
|
|
278
|
+
toolkit.definitions.some((tool) => tool.name === call.name) && !cutOff.some((cut) => cut.id === call.id)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if (cutOff.length > 0 || approved.length > 0) {
|
|
282
|
+
const pending = [
|
|
283
|
+
...cutOff.map((call) => ({ call, approved: false })),
|
|
284
|
+
...approved.map((call) => ({ call, approved: true }))
|
|
285
|
+
]
|
|
204
286
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
after({ output: undefined, done: false, historyLength: history.length + cutOff.length })
|
|
287
|
+
return runCalls(toolkit, pending, history, ({ waiting }) =>
|
|
288
|
+
after({ output: undefined, done: waiting, historyLength: history.length + pending.length })
|
|
208
289
|
)
|
|
209
290
|
}
|
|
210
291
|
|
|
@@ -230,15 +311,20 @@ export const step = <R, S, E2 = never, R2 = never>(
|
|
|
230
311
|
const calls = toolCalls(output.parts)
|
|
231
312
|
const historyLength = history.length + Prompt.fromResponseParts(output.parts).content.length + calls.length
|
|
232
313
|
|
|
233
|
-
// A failed call doesn't end the turn: a message that failed never reached the user, so the model reads why.
|
|
314
|
+
// A failed call doesn't end the turn: a message that failed never reached the user, so the model reads why. A call
|
|
315
|
+
// that waits for approval does: the turn waits for the user.
|
|
234
316
|
return Stream.make(value(output)).pipe(
|
|
235
317
|
Stream.concat(
|
|
236
|
-
runCalls(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
318
|
+
runCalls(
|
|
319
|
+
toolkit,
|
|
320
|
+
calls.map((call) => ({ call, approved: false })),
|
|
321
|
+
history,
|
|
322
|
+
({ failed, waiting }) =>
|
|
323
|
+
after({
|
|
324
|
+
output,
|
|
325
|
+
done: calls.length === 0 || waiting || (!failed && calls.some(isTurnEnding)),
|
|
326
|
+
historyLength
|
|
327
|
+
})
|
|
242
328
|
)
|
|
243
329
|
)
|
|
244
330
|
)
|
|
@@ -22,12 +22,9 @@ import * as Loop from "./loop.ts"
|
|
|
22
22
|
import { CurrentChat } from "../CurrentChat.ts"
|
|
23
23
|
import * as InputId from "../InputId.ts"
|
|
24
24
|
import * as RunId from "../RunId.ts"
|
|
25
|
-
import * as Skill from "../Skill.ts"
|
|
26
|
-
import * as Skills from "./skills.ts"
|
|
27
25
|
import * as Specialist from "../Specialist.ts"
|
|
28
26
|
import * as ToolFailure from "../ToolFailure.ts"
|
|
29
27
|
import { toolMessage, userText } from "./history.ts"
|
|
30
|
-
import { mapText } from "./instructions.ts"
|
|
31
28
|
import * as Telemetry from "./telemetry.ts"
|
|
32
29
|
import * as Toolkits from "./toolkit.ts"
|
|
33
30
|
import type { Writer } from "./writer.ts"
|
|
@@ -83,22 +80,19 @@ export const runBrief = Effect.fnUntraced(
|
|
|
83
80
|
chatId: options.chatId,
|
|
84
81
|
brief: options.brief,
|
|
85
82
|
history: current,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
instructions: mapText(loop.instructions, (text) => Skill.withCatalog(text, skills)),
|
|
92
|
-
toolkit: Toolkits.merge(Skills.toTools(skills), own),
|
|
83
|
+
// A run can't wait for the user's approval: a call that needs it fails, and the run reads why.
|
|
84
|
+
loop: (loop) =>
|
|
85
|
+
Loop.run({
|
|
86
|
+
instructions: loop.instructions,
|
|
87
|
+
toolkit: Toolkits.fromToolkitOrEmpty(loop.toolkit),
|
|
93
88
|
history: current,
|
|
94
89
|
maxSteps: loop.maxSteps ?? 8
|
|
95
|
-
})
|
|
96
|
-
}
|
|
90
|
+
}).pipe(Stream.provideService(Loop.TurnApprovals, Option.none()))
|
|
97
91
|
}
|
|
98
92
|
|
|
99
93
|
// A run lives in memory, so there's nothing to run again after a crash: `ToolStarted` isn't kept.
|
|
100
94
|
const record = Effect.fnUntraced(function* (event: AssistantLoop.Event) {
|
|
101
|
-
if (Predicate.isTagged(event, "ToolStarted")) return
|
|
95
|
+
if (Predicate.isTagged(event, "ToolStarted") || Predicate.isTagged(event, "ApprovalRequested")) return
|
|
102
96
|
|
|
103
97
|
if (Predicate.isTagged(event, "ToolResult")) return yield* add([toolMessage(event)])
|
|
104
98
|
|
package/src/internal/toolkit.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import * as AiError from "effect/ai/AiError"
|
|
2
|
+
import type * as Prompt from "effect/ai/Prompt"
|
|
2
3
|
import type * as Tool from "effect/ai/Tool"
|
|
3
4
|
import type * as Toolkit from "effect/ai/Toolkit"
|
|
4
5
|
import type * as Context from "effect/Context"
|
|
5
6
|
import * as Effect from "effect/Effect"
|
|
6
7
|
import * as Option from "effect/Option"
|
|
8
|
+
import * as Predicate from "effect/Predicate"
|
|
7
9
|
import * as Schema from "effect/Schema"
|
|
8
10
|
import * as Stream from "effect/Stream"
|
|
9
11
|
import type * as AssistantEvent from "../AssistantEvent.ts"
|
|
@@ -23,13 +25,18 @@ export interface CallResult {
|
|
|
23
25
|
export interface Tools<out R = never> {
|
|
24
26
|
readonly definitions: ReadonlyArray<Tool.Any>
|
|
25
27
|
readonly run: (call: AssistantEvent.ToolCallPart) => Effect.Effect<CallResult, never, R>
|
|
28
|
+
// Whether the call waits for the user's approval instead of running: its tool's `needsApproval`.
|
|
29
|
+
readonly needsApproval: (
|
|
30
|
+
call: AssistantEvent.ToolCallPart,
|
|
31
|
+
history: ReadonlyArray<Prompt.Message>
|
|
32
|
+
) => Effect.Effect<boolean, never, R>
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
/** @internal */
|
|
29
|
-
export type ToolServices<T extends Record<string, Tool.Any>> =
|
|
30
|
-
Tool.HandlerServices<T[keyof T]>,
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
export type ToolServices<T extends Record<string, Tool.Any>> =
|
|
37
|
+
| Exclude<Tool.HandlerServices<T[keyof T]>, AssistantToolCall>
|
|
38
|
+
// Already in the handlers' services; stated for the loop, which decodes params to ask `needsApproval`.
|
|
39
|
+
| T[keyof T]["parametersSchema"]["DecodingServices"]
|
|
33
40
|
|
|
34
41
|
const encodeFailure = Schema.encodeSync(ToolFailure.ToolFailure)
|
|
35
42
|
|
|
@@ -60,15 +67,32 @@ const hasTool = <T extends Record<string, Tool.Any>>(
|
|
|
60
67
|
name: PropertyKey
|
|
61
68
|
): name is keyof T => Object.hasOwn(toolkit.tools, name)
|
|
62
69
|
|
|
63
|
-
// Approval would park a call until the user decides, which this library doesn't do: the model calls a tool with
|
|
64
|
-
// final values, and a user's own commitment goes through the app's API from a block.
|
|
65
70
|
/** @internal */
|
|
66
71
|
export const needingApproval = (definitions: ReadonlyArray<Tool.Any>): ReadonlyArray<string> =>
|
|
67
72
|
definitions.flatMap((tool) => (tool.needsApproval === undefined || tool.needsApproval === false ? [] : [tool.name]))
|
|
68
73
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
// With noUncheckedIndexedAccess, an index doesn't narrow on `hasTool`.
|
|
75
|
+
const toolNamed = (toolkit: { readonly tools: Record<string, Tool.Any> }, name: string): Tool.Any | undefined =>
|
|
76
|
+
Object.hasOwn(toolkit.tools, name) ? toolkit.tools[name] : undefined
|
|
77
|
+
|
|
78
|
+
// Params that don't fit the tool don't wait: the call runs, and fails with why.
|
|
79
|
+
const needsApprovalNative = Effect.fnUntraced(function* <T extends Record<string, Tool.Any>>(
|
|
80
|
+
toolkit: { readonly tools: T },
|
|
81
|
+
call: AssistantEvent.ToolCallPart,
|
|
82
|
+
history: ReadonlyArray<Prompt.Message>
|
|
83
|
+
): Effect.fn.Return<boolean, never, ToolServices<T>> {
|
|
84
|
+
const tool = toolNamed(toolkit, call.name)
|
|
85
|
+
const needs = tool?.needsApproval
|
|
86
|
+
|
|
87
|
+
if (tool === undefined || needs === undefined || Predicate.isBoolean(needs)) return needs === true
|
|
88
|
+
|
|
89
|
+
return yield* Effect.gen(function* () {
|
|
90
|
+
const params = yield* Schema.decodeUnknownEffect(tool.parametersSchema)(call.params)
|
|
91
|
+
const decided = needs(params, { toolCallId: call.id, messages: history })
|
|
92
|
+
|
|
93
|
+
return Effect.isEffect(decided) ? yield* decided : decided
|
|
94
|
+
}).pipe(Effect.orElseSucceed(() => false))
|
|
95
|
+
})
|
|
72
96
|
|
|
73
97
|
// The toolkit decodes the params with the tool's schema before its handler sees them, and a call whose params don't
|
|
74
98
|
// fit fails with a validation error the model reads: the model's JSON is what `handle` takes.
|
|
@@ -117,7 +141,8 @@ export const fromWithHandler = <T extends Record<string, Tool.Any>>(
|
|
|
117
141
|
toolkit: Toolkit.WithHandler<T>
|
|
118
142
|
): Tools<ToolServices<T>> => ({
|
|
119
143
|
definitions: Object.values(toolkit.tools),
|
|
120
|
-
run: (call) => runNative(toolkit, call)
|
|
144
|
+
run: (call) => runNative(toolkit, call),
|
|
145
|
+
needsApproval: (call, history) => needsApprovalNative(toolkit, call, history)
|
|
121
146
|
})
|
|
122
147
|
|
|
123
148
|
/** @internal */
|
|
@@ -127,22 +152,8 @@ export const fromToolkit = <T extends Record<string, Tool.Any>>(
|
|
|
127
152
|
definitions: Object.values(toolkit.tools),
|
|
128
153
|
run: Effect.fnUntraced(function* (call) {
|
|
129
154
|
return yield* runNative(yield* toolkit, call)
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// The handlers use services of the loop they run in, so they're made for each call, with its context.
|
|
134
|
-
/** @internal */
|
|
135
|
-
export const withContext = <T extends Record<string, Tool.Any>, R>(
|
|
136
|
-
toolkit: Toolkit.Toolkit<T>,
|
|
137
|
-
handlers: (context: Context.Context<R>) => Toolkit.HandlersFrom<T>
|
|
138
|
-
): Tools<R | ToolServices<T>> => ({
|
|
139
|
-
definitions: Object.values(toolkit.tools),
|
|
140
|
-
run: Effect.fnUntraced(function* (call) {
|
|
141
|
-
const context = yield* Effect.context<R>()
|
|
142
|
-
const resolved = yield* Effect.provideContext(toolkit, yield* toolkit.toHandlers(handlers(context)))
|
|
143
|
-
|
|
144
|
-
return yield* runNative(resolved, call)
|
|
145
|
-
})
|
|
155
|
+
}),
|
|
156
|
+
needsApproval: (call, history) => needsApprovalNative(toolkit, call, history)
|
|
146
157
|
})
|
|
147
158
|
|
|
148
159
|
/** @internal */
|
|
@@ -153,7 +164,8 @@ export const fromToolkitOrEmpty = <T extends Record<string, Tool.Any>>(
|
|
|
153
164
|
/** @internal */
|
|
154
165
|
export const empty: Tools = {
|
|
155
166
|
definitions: [],
|
|
156
|
-
run: (call) => Effect.succeed(failed(`No tool named ${call.name}.`, "ToolNotFoundError"))
|
|
167
|
+
run: (call) => Effect.succeed(failed(`No tool named ${call.name}.`, "ToolNotFoundError")),
|
|
168
|
+
needsApproval: () => Effect.succeed(false)
|
|
157
169
|
}
|
|
158
170
|
|
|
159
171
|
/** @internal */
|
|
@@ -162,7 +174,9 @@ export const merge = <A, B>(self: Tools<A>, that: Tools<B>): Tools<A | B> => {
|
|
|
162
174
|
|
|
163
175
|
return {
|
|
164
176
|
definitions: [...self.definitions, ...that.definitions],
|
|
165
|
-
run: (call) => (inSelf(call) ? self.run(call) : that.run(call))
|
|
177
|
+
run: (call) => (inSelf(call) ? self.run(call) : that.run(call)),
|
|
178
|
+
needsApproval: (call, history) =>
|
|
179
|
+
inSelf(call) ? self.needsApproval(call, history) : that.needsApproval(call, history)
|
|
166
180
|
}
|
|
167
181
|
}
|
|
168
182
|
|
|
@@ -170,7 +184,8 @@ export const merge = <A, B>(self: Tools<A>, that: Tools<B>): Tools<A | B> => {
|
|
|
170
184
|
/** @internal */
|
|
171
185
|
export const provide = <R>(tools: Tools<R>, context: Context.Context<R>): Tools => ({
|
|
172
186
|
definitions: tools.definitions,
|
|
173
|
-
run: (call) => Effect.provideContext(tools.run(call), context)
|
|
187
|
+
run: (call) => Effect.provideContext(tools.run(call), context),
|
|
188
|
+
needsApproval: (call, history) => Effect.provideContext(tools.needsApproval(call, history), context)
|
|
174
189
|
})
|
|
175
190
|
|
|
176
191
|
/** @internal */
|
|
@@ -184,5 +199,7 @@ export const duplicateNames = (tools: Tools<unknown>): ReadonlyArray<string> =>
|
|
|
184
199
|
/** @internal */
|
|
185
200
|
export const runOnly = <R>(tools: Tools<R>, names: ReadonlyArray<string>, message: string): Tools<R> => ({
|
|
186
201
|
definitions: tools.definitions,
|
|
187
|
-
run: (call) => (names.includes(call.name) ? tools.run(call) : Effect.succeed(failed(message, "NotNow")))
|
|
202
|
+
run: (call) => (names.includes(call.name) ? tools.run(call) : Effect.succeed(failed(message, "NotNow"))),
|
|
203
|
+
// Refused like any other call it doesn't run.
|
|
204
|
+
needsApproval: () => Effect.succeed(false)
|
|
188
205
|
})
|
package/src/internal/turns.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type * as LanguageModel from "effect/ai/LanguageModel"
|
|
2
|
+
import * as Response from "effect/ai/Response"
|
|
2
3
|
import type * as Tool from "effect/ai/Tool"
|
|
3
4
|
import * as Cause from "effect/Cause"
|
|
4
5
|
import * as Context from "effect/Context"
|
|
@@ -19,8 +20,7 @@ import * as Loop from "./loop.ts"
|
|
|
19
20
|
import type * as BlockKit from "../BlockKit.ts"
|
|
20
21
|
import type { CurrentChat } from "../CurrentChat.ts"
|
|
21
22
|
import * as Instructions from "../Instructions.ts"
|
|
22
|
-
import * as
|
|
23
|
-
import * as Skills from "./skills.ts"
|
|
23
|
+
import * as ToolApproval from "../ToolApproval.ts"
|
|
24
24
|
import type * as Specialist from "../Specialist.ts"
|
|
25
25
|
import * as AppEvents from "./events.ts"
|
|
26
26
|
import * as History from "./history.ts"
|
|
@@ -125,6 +125,21 @@ const failureMessage = (cause: Cause.Cause<unknown>): string =>
|
|
|
125
125
|
onSome: ({ _tag, raw }) => `${_tag ?? "Error"}: ${Predicate.isString(raw) ? raw : toJsonText(raw)}`
|
|
126
126
|
})
|
|
127
127
|
|
|
128
|
+
// Approved, and not run yet: also one a crash cut off while it ran, which runs again with the same id.
|
|
129
|
+
const approvedCalls = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): ReadonlyArray<AssistantEvent.ToolCallPart> =>
|
|
130
|
+
[...ToolApproval.fromEntries(entries).values()].flatMap(({ request, status, outcome }) =>
|
|
131
|
+
status === "approved" && outcome === undefined
|
|
132
|
+
? [
|
|
133
|
+
Response.makePart("tool-call", {
|
|
134
|
+
id: request.callId,
|
|
135
|
+
name: request.tool,
|
|
136
|
+
params: request.params,
|
|
137
|
+
providerExecuted: false
|
|
138
|
+
})
|
|
139
|
+
]
|
|
140
|
+
: []
|
|
141
|
+
)
|
|
142
|
+
|
|
128
143
|
const lastSeq = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, otherwise: number) => entries.at(-1)?.seq ?? otherwise
|
|
129
144
|
|
|
130
145
|
// The handler's stream can end between a step's output and its tools, or while they run. An interrupted turn (a
|
|
@@ -266,7 +281,8 @@ const makeTurnApi = (
|
|
|
266
281
|
}),
|
|
267
282
|
// A second step, for a reply after a call it was refused.
|
|
268
283
|
maxSteps: 2
|
|
269
|
-
|
|
284
|
+
// The loop ran the approved calls; a pass that can only reply runs none.
|
|
285
|
+
}).pipe(Stream.provideService(Loop.TurnApprovals, Option.none()))
|
|
270
286
|
})
|
|
271
287
|
)
|
|
272
288
|
|
|
@@ -280,13 +296,12 @@ const makeTurnApi = (
|
|
|
280
296
|
})
|
|
281
297
|
)
|
|
282
298
|
|
|
283
|
-
const prepare = <T extends Record<string, Tool.Any
|
|
299
|
+
const prepare = <T extends Record<string, Tool.Any>>(options: Assistant.LoopOptions<T, any>) => {
|
|
284
300
|
const own = Toolkits.fromToolkitOrEmpty(options.toolkit)
|
|
285
|
-
const skills = Skills.available(options.skills ?? [], [...builtins.definitions, ...own.definitions])
|
|
286
301
|
|
|
287
302
|
return {
|
|
288
|
-
system:
|
|
289
|
-
toolkit: Toolkits.merge(
|
|
303
|
+
system: instructions(options.instructions),
|
|
304
|
+
toolkit: Toolkits.merge(builtins, own),
|
|
290
305
|
view: options.history ?? identity
|
|
291
306
|
}
|
|
292
307
|
}
|
|
@@ -357,6 +372,8 @@ export const run = Effect.fnUntraced(
|
|
|
357
372
|
Loop.TurnTools,
|
|
358
373
|
Option.some(Toolkits.provide(options.registration.builtins, options.services))
|
|
359
374
|
),
|
|
375
|
+
// A turn's loop waits for the user's approval, and runs the calls they approved.
|
|
376
|
+
Effect.provideService(Loop.TurnApprovals, Option.some({ approved: Effect.map(chat.entries, approvedCalls) })),
|
|
360
377
|
Effect.provideContext(options.services),
|
|
361
378
|
Effect.matchCauseEffect({
|
|
362
379
|
onSuccess: Effect.fnUntraced(function* () {
|
package/dist/internal/skills.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { ToolFailure, fail } from "../ToolFailure.js";
|
|
2
|
-
import { empty, withContext } from "./toolkit.js";
|
|
3
|
-
import { resolve } from "../Instructions.js";
|
|
4
|
-
import * as Effect from "effect/Effect";
|
|
5
|
-
import * as Schema from "effect/Schema";
|
|
6
|
-
import * as Tool from "effect/ai/Tool";
|
|
7
|
-
import * as Toolkit from "effect/ai/Toolkit";
|
|
8
|
-
//#region src/internal/skills.ts
|
|
9
|
-
/** @internal */
|
|
10
|
-
const available = (skills, definitions) => {
|
|
11
|
-
const names = new Set(definitions.map((tool) => tool.name));
|
|
12
|
-
return skills.filter((skill) => (skill.tools ?? []).every((tool) => names.has(tool.name)));
|
|
13
|
-
};
|
|
14
|
-
/** @internal */
|
|
15
|
-
const toTools = (skills) => {
|
|
16
|
-
if (skills.length === 0) return empty;
|
|
17
|
-
const byName = new Map(skills.map((skill) => [skill.name, skill]));
|
|
18
|
-
const LoadSkill = Tool.make("load_skill", {
|
|
19
|
-
description: "Load a skill's instructions by name, in the same step as your first lookups (not in a step of its own). Follow them for the rest of the task.",
|
|
20
|
-
parameters: Schema.Struct({ name: Schema.Literals(skills.map((skill) => skill.name)) }),
|
|
21
|
-
success: Schema.String,
|
|
22
|
-
failure: ToolFailure
|
|
23
|
-
});
|
|
24
|
-
return withContext(Toolkit.make(LoadSkill), (context) => ({ load_skill: Effect.fnUntraced(function* ({ name }) {
|
|
25
|
-
const skill = byName.get(name);
|
|
26
|
-
if (skill === void 0) return yield* fail(`No skill named ${name}.`);
|
|
27
|
-
return (yield* resolve(skill.instructions).pipe(Effect.provideContext(context))).text;
|
|
28
|
-
}) }));
|
|
29
|
-
};
|
|
30
|
-
//#endregion
|
|
31
|
-
export { available, toTools };
|
|
32
|
-
|
|
33
|
-
//# sourceMappingURL=skills.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"skills.js","names":["Toolkits.empty","ToolFailure.ToolFailure","Toolkits.withContext","ToolFailure.fail","Instructions.resolve"],"sources":["../../src/internal/skills.ts"],"sourcesContent":["import * as Tool from \"effect/ai/Tool\"\nimport * as Toolkit from \"effect/ai/Toolkit\"\nimport type * as Context from \"effect/Context\"\nimport * as Effect from \"effect/Effect\"\nimport * as Schema from \"effect/Schema\"\nimport * as Instructions from \"../Instructions.ts\"\nimport type { Skill } from \"../Skill.ts\"\nimport * as ToolFailure from \"../ToolFailure.ts\"\nimport * as Toolkits from \"./toolkit.ts\"\n\n// A skill whose tools the loop doesn't have would send the model after calls it can't make.\n/** @internal */\nexport const available = <R>(\n skills: ReadonlyArray<Skill<R>>,\n definitions: ReadonlyArray<Tool.Any>\n): ReadonlyArray<Skill<R>> => {\n const names = new Set(definitions.map((tool) => tool.name))\n\n return skills.filter((skill) => (skill.tools ?? []).every((tool) => names.has(tool.name)))\n}\n\n/** @internal */\nexport const toTools = <R>(skills: ReadonlyArray<Skill<R>>): Toolkits.Tools<R> => {\n if (skills.length === 0) return Toolkits.empty\n\n const byName = new Map(skills.map((skill) => [skill.name, skill]))\n\n const LoadSkill = Tool.make(\"load_skill\", {\n description:\n \"Load a skill's instructions by name, in the same step as your first lookups (not in a step of its own). Follow them for the rest of the task.\",\n parameters: Schema.Struct({ name: Schema.Literals(skills.map((skill) => skill.name)) }),\n success: Schema.String,\n failure: ToolFailure.ToolFailure\n })\n\n // A skill's instructions resolve with the services of the loop that loads it.\n return Toolkits.withContext(Toolkit.make(LoadSkill), (context: Context.Context<R>) => ({\n load_skill: Effect.fnUntraced(function* ({ name }) {\n const skill = byName.get(name)\n\n if (skill === undefined) return yield* ToolFailure.fail(`No skill named ${name}.`)\n\n const resolved = yield* Instructions.resolve(skill.instructions).pipe(Effect.provideContext(context))\n\n return resolved.text\n })\n }))\n}\n"],"mappings":";;;;;;;;;AAYA,MAAa,aACX,QACA,gBAC4B;CAC5B,MAAM,QAAQ,IAAI,IAAI,YAAY,KAAK,SAAS,KAAK,IAAI,CAAC;CAE1D,OAAO,OAAO,QAAQ,WAAW,MAAM,SAAS,CAAC,EAAA,CAAG,OAAO,SAAS,MAAM,IAAI,KAAK,IAAI,CAAC,CAAC;AAC3F;;AAGA,MAAa,WAAc,WAAuD;CAChF,IAAI,OAAO,WAAW,GAAG,OAAOA;CAEhC,MAAM,SAAS,IAAI,IAAI,OAAO,KAAK,UAAU,CAAC,MAAM,MAAM,KAAK,CAAC,CAAC;CAEjE,MAAM,YAAY,KAAK,KAAK,cAAc;EACxC,aACE;EACF,YAAY,OAAO,OAAO,EAAE,MAAM,OAAO,SAAS,OAAO,KAAK,UAAU,MAAM,IAAI,CAAC,EAAE,CAAC;EACtF,SAAS,OAAO;EAChB,SAASC;CACX,CAAC;CAGD,OAAOC,YAAqB,QAAQ,KAAK,SAAS,IAAI,aAAiC,EACrF,YAAY,OAAO,WAAW,WAAW,EAAE,QAAQ;EACjD,MAAM,QAAQ,OAAO,IAAI,IAAI;EAE7B,IAAI,UAAU,KAAA,GAAW,OAAO,OAAOC,KAAiB,kBAAkB,KAAK,EAAE;EAIjF,QAAO,OAFiBC,QAAqB,MAAM,YAAY,CAAC,CAAC,KAAK,OAAO,eAAe,OAAO,CAAC,EAAA,CAEpF;CAClB,CAAC,EACH,EAAE;AACJ"}
|
package/src/internal/skills.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import * as Tool from "effect/ai/Tool"
|
|
2
|
-
import * as Toolkit from "effect/ai/Toolkit"
|
|
3
|
-
import type * as Context from "effect/Context"
|
|
4
|
-
import * as Effect from "effect/Effect"
|
|
5
|
-
import * as Schema from "effect/Schema"
|
|
6
|
-
import * as Instructions from "../Instructions.ts"
|
|
7
|
-
import type { Skill } from "../Skill.ts"
|
|
8
|
-
import * as ToolFailure from "../ToolFailure.ts"
|
|
9
|
-
import * as Toolkits from "./toolkit.ts"
|
|
10
|
-
|
|
11
|
-
// A skill whose tools the loop doesn't have would send the model after calls it can't make.
|
|
12
|
-
/** @internal */
|
|
13
|
-
export const available = <R>(
|
|
14
|
-
skills: ReadonlyArray<Skill<R>>,
|
|
15
|
-
definitions: ReadonlyArray<Tool.Any>
|
|
16
|
-
): ReadonlyArray<Skill<R>> => {
|
|
17
|
-
const names = new Set(definitions.map((tool) => tool.name))
|
|
18
|
-
|
|
19
|
-
return skills.filter((skill) => (skill.tools ?? []).every((tool) => names.has(tool.name)))
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** @internal */
|
|
23
|
-
export const toTools = <R>(skills: ReadonlyArray<Skill<R>>): Toolkits.Tools<R> => {
|
|
24
|
-
if (skills.length === 0) return Toolkits.empty
|
|
25
|
-
|
|
26
|
-
const byName = new Map(skills.map((skill) => [skill.name, skill]))
|
|
27
|
-
|
|
28
|
-
const LoadSkill = Tool.make("load_skill", {
|
|
29
|
-
description:
|
|
30
|
-
"Load a skill's instructions by name, in the same step as your first lookups (not in a step of its own). Follow them for the rest of the task.",
|
|
31
|
-
parameters: Schema.Struct({ name: Schema.Literals(skills.map((skill) => skill.name)) }),
|
|
32
|
-
success: Schema.String,
|
|
33
|
-
failure: ToolFailure.ToolFailure
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
// A skill's instructions resolve with the services of the loop that loads it.
|
|
37
|
-
return Toolkits.withContext(Toolkit.make(LoadSkill), (context: Context.Context<R>) => ({
|
|
38
|
-
load_skill: Effect.fnUntraced(function* ({ name }) {
|
|
39
|
-
const skill = byName.get(name)
|
|
40
|
-
|
|
41
|
-
if (skill === undefined) return yield* ToolFailure.fail(`No skill named ${name}.`)
|
|
42
|
-
|
|
43
|
-
const resolved = yield* Instructions.resolve(skill.instructions).pipe(Effect.provideContext(context))
|
|
44
|
-
|
|
45
|
-
return resolved.text
|
|
46
|
-
})
|
|
47
|
-
}))
|
|
48
|
-
}
|