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.
Files changed (73) hide show
  1. package/dist/Assistant.d.ts +5 -11
  2. package/dist/Assistant.d.ts.map +1 -1
  3. package/dist/Assistant.js.map +1 -1
  4. package/dist/AssistantEvent.d.ts +55 -7
  5. package/dist/AssistantEvent.d.ts.map +1 -1
  6. package/dist/AssistantEvent.js +61 -7
  7. package/dist/AssistantEvent.js.map +1 -1
  8. package/dist/AssistantLoop.d.ts +4 -2
  9. package/dist/AssistantLoop.d.ts.map +1 -1
  10. package/dist/AssistantLoop.js +4 -2
  11. package/dist/AssistantLoop.js.map +1 -1
  12. package/dist/Compaction.d.ts +1 -1
  13. package/dist/Compaction.d.ts.map +1 -1
  14. package/dist/Compaction.js +13 -6
  15. package/dist/Compaction.js.map +1 -1
  16. package/dist/Skill.d.ts +85 -43
  17. package/dist/Skill.d.ts.map +1 -1
  18. package/dist/Skill.js +93 -44
  19. package/dist/Skill.js.map +1 -1
  20. package/dist/Specialist.d.ts +6 -9
  21. package/dist/Specialist.d.ts.map +1 -1
  22. package/dist/Specialist.js.map +1 -1
  23. package/dist/Timeline.d.ts +40 -6
  24. package/dist/Timeline.d.ts.map +1 -1
  25. package/dist/Timeline.js +74 -15
  26. package/dist/Timeline.js.map +1 -1
  27. package/dist/ToolApproval.d.ts +146 -0
  28. package/dist/ToolApproval.d.ts.map +1 -0
  29. package/dist/ToolApproval.js +201 -0
  30. package/dist/ToolApproval.js.map +1 -0
  31. package/dist/ToolDiscovery.d.ts +6 -4
  32. package/dist/ToolDiscovery.d.ts.map +1 -1
  33. package/dist/ToolDiscovery.js +5 -4
  34. package/dist/ToolDiscovery.js.map +1 -1
  35. package/dist/index.d.ts +3 -2
  36. package/dist/index.js +3 -2
  37. package/dist/internal/chat.js +4 -1
  38. package/dist/internal/chat.js.map +1 -1
  39. package/dist/internal/history.js +28 -3
  40. package/dist/internal/history.js.map +1 -1
  41. package/dist/internal/loop.d.ts +10 -5
  42. package/dist/internal/loop.d.ts.map +1 -1
  43. package/dist/internal/loop.js +54 -21
  44. package/dist/internal/loop.js.map +1 -1
  45. package/dist/internal/specialists.js +9 -16
  46. package/dist/internal/specialists.js.map +1 -1
  47. package/dist/internal/toolkit.d.ts +5 -7
  48. package/dist/internal/toolkit.d.ts.map +1 -1
  49. package/dist/internal/toolkit.js +28 -18
  50. package/dist/internal/toolkit.js.map +1 -1
  51. package/dist/internal/turns.d.ts.map +1 -1
  52. package/dist/internal/turns.js +18 -13
  53. package/dist/internal/turns.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/Assistant.ts +7 -13
  56. package/src/AssistantEvent.ts +60 -6
  57. package/src/AssistantLoop.ts +4 -2
  58. package/src/Compaction.ts +19 -2
  59. package/src/Skill.ts +134 -57
  60. package/src/Specialist.ts +8 -11
  61. package/src/Timeline.ts +104 -18
  62. package/src/ToolApproval.ts +249 -0
  63. package/src/ToolDiscovery.ts +8 -3
  64. package/src/index.ts +5 -0
  65. package/src/internal/chat.ts +11 -1
  66. package/src/internal/history.ts +58 -3
  67. package/src/internal/loop.ts +120 -34
  68. package/src/internal/specialists.ts +7 -13
  69. package/src/internal/toolkit.ts +47 -30
  70. package/src/internal/turns.ts +24 -7
  71. package/dist/internal/skills.js +0 -33
  72. package/dist/internal/skills.js.map +0 -1
  73. package/src/internal/skills.ts +0 -48
@@ -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 = AssistantEvent.ModelOutput | AssistantEvent.ToolStarted | AssistantEvent.ToolResult
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, or ended its turn (`wait`, or a message
65
- * with `wait: true`) and none of the step's calls failed.
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(definitions.map((tool) => [tool.name, tool])),
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
- // Every `ToolStarted` is stored before any call runs. `then` is told whether one of the calls failed, so the model
115
- // has to read the results.
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
- calls: ReadonlyArray<AssistantEvent.ToolCallPart>,
119
- then: (failed: boolean) => Stream.Stream<Step<never, S>, E, R2>
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.fromIterable(
122
- calls.map((call) => value(AssistantEvent.ToolStarted.make({ callId: call.id, tool: call.name })))
123
- ).pipe(
124
- Stream.concat(
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 = (result: AssistantEvent.ToolResult) => result.outcome === "failure"
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
- if (cutOff.length > 0) {
206
- return runCalls(toolkit, cutOff, () =>
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(toolkit, calls, (failed) =>
237
- after({
238
- output,
239
- done: calls.length === 0 || (!failed && calls.some(isTurnEnding)),
240
- historyLength
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
- loop: (loop) => {
87
- const own = Toolkits.fromToolkitOrEmpty(loop.toolkit)
88
- const skills = Skills.available(loop.skills ?? [], own.definitions)
89
-
90
- return Loop.run({
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
 
@@ -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>> = Exclude<
30
- Tool.HandlerServices<T[keyof T]>,
31
- AssistantToolCall
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
- /** @internal */
70
- export const needsApprovalMessage = (names: ReadonlyArray<string>): string =>
71
- `${names.join(", ")} ${names.length === 1 ? "sets" : "set"} needsApproval, and effect-assistant doesn't run tools that wait for approval. Remove needsApproval: let the model call the tool with final values, or show a block whose button calls your app's API, which does the work and submits an app event that wakes the chat.`
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
  })
@@ -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 Skill from "../Skill.ts"
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>, RS>(options: Assistant.LoopOptions<T, RS, 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: mapText(instructions(options.instructions), (text) => Skill.withCatalog(text, skills)),
289
- toolkit: Toolkits.merge(Toolkits.merge(builtins, Skills.toTools(skills)), own),
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* () {
@@ -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"}
@@ -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
- }