effect-assistant 0.3.0 → 0.5.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 (91) 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 +66 -7
  5. package/dist/AssistantEvent.d.ts.map +1 -1
  6. package/dist/AssistantEvent.js +74 -9
  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/Block.d.ts +63 -1
  13. package/dist/Block.d.ts.map +1 -1
  14. package/dist/Block.js +63 -2
  15. package/dist/Block.js.map +1 -1
  16. package/dist/BlockKit.d.ts +25 -3
  17. package/dist/BlockKit.d.ts.map +1 -1
  18. package/dist/BlockKit.js +14 -12
  19. package/dist/BlockKit.js.map +1 -1
  20. package/dist/Compaction.d.ts +1 -1
  21. package/dist/Compaction.d.ts.map +1 -1
  22. package/dist/Compaction.js +11 -4
  23. package/dist/Compaction.js.map +1 -1
  24. package/dist/Skill.d.ts +85 -43
  25. package/dist/Skill.d.ts.map +1 -1
  26. package/dist/Skill.js +93 -44
  27. package/dist/Skill.js.map +1 -1
  28. package/dist/Specialist.d.ts +6 -9
  29. package/dist/Specialist.d.ts.map +1 -1
  30. package/dist/Specialist.js.map +1 -1
  31. package/dist/Timeline.d.ts +44 -6
  32. package/dist/Timeline.d.ts.map +1 -1
  33. package/dist/Timeline.js +87 -16
  34. package/dist/Timeline.js.map +1 -1
  35. package/dist/ToolApproval.d.ts +146 -0
  36. package/dist/ToolApproval.d.ts.map +1 -0
  37. package/dist/ToolApproval.js +201 -0
  38. package/dist/ToolApproval.js.map +1 -0
  39. package/dist/ToolDiscovery.d.ts +6 -4
  40. package/dist/ToolDiscovery.d.ts.map +1 -1
  41. package/dist/ToolDiscovery.js +5 -4
  42. package/dist/ToolDiscovery.js.map +1 -1
  43. package/dist/ToolResult.d.ts +56 -0
  44. package/dist/ToolResult.d.ts.map +1 -0
  45. package/dist/ToolResult.js +49 -0
  46. package/dist/ToolResult.js.map +1 -0
  47. package/dist/index.d.ts +4 -2
  48. package/dist/index.js +6 -4
  49. package/dist/internal/chat.js +4 -1
  50. package/dist/internal/chat.js.map +1 -1
  51. package/dist/internal/history.js +28 -3
  52. package/dist/internal/history.js.map +1 -1
  53. package/dist/internal/loop.d.ts +10 -5
  54. package/dist/internal/loop.d.ts.map +1 -1
  55. package/dist/internal/loop.js +60 -21
  56. package/dist/internal/loop.js.map +1 -1
  57. package/dist/internal/specialists.js +9 -16
  58. package/dist/internal/specialists.js.map +1 -1
  59. package/dist/internal/toolkit.d.ts +11 -7
  60. package/dist/internal/toolkit.d.ts.map +1 -1
  61. package/dist/internal/toolkit.js +74 -21
  62. package/dist/internal/toolkit.js.map +1 -1
  63. package/dist/internal/tools.js +39 -6
  64. package/dist/internal/tools.js.map +1 -1
  65. package/dist/internal/turns.d.ts.map +1 -1
  66. package/dist/internal/turns.js +24 -16
  67. package/dist/internal/turns.js.map +1 -1
  68. package/package.json +1 -1
  69. package/src/Assistant.ts +7 -13
  70. package/src/AssistantEvent.ts +73 -8
  71. package/src/AssistantLoop.ts +4 -2
  72. package/src/Block.ts +82 -1
  73. package/src/BlockKit.ts +37 -4
  74. package/src/Compaction.ts +19 -2
  75. package/src/Skill.ts +134 -57
  76. package/src/Specialist.ts +8 -11
  77. package/src/Timeline.ts +120 -21
  78. package/src/ToolApproval.ts +249 -0
  79. package/src/ToolDiscovery.ts +8 -3
  80. package/src/ToolResult.ts +80 -0
  81. package/src/index.ts +10 -0
  82. package/src/internal/chat.ts +11 -1
  83. package/src/internal/history.ts +58 -3
  84. package/src/internal/loop.ts +138 -35
  85. package/src/internal/specialists.ts +7 -13
  86. package/src/internal/toolkit.ts +102 -35
  87. package/src/internal/tools.ts +81 -5
  88. package/src/internal/turns.ts +31 -10
  89. package/dist/internal/skills.js +0 -33
  90. package/dist/internal/skills.js.map +0 -1
  91. package/src/internal/skills.ts +0 -48
@@ -18,10 +18,21 @@ import type * as Instructions from "../Instructions.ts"
18
18
  import * as History from "./history.ts"
19
19
  import * as Telemetry from "./telemetry.ts"
20
20
  import * as Toolkits from "./toolkit.ts"
21
- import { isTurnEnding } from "./tools.ts"
21
+ import { isTurnEnding, isUserMessage } 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,96 @@ 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
+ // The approval shows the call, not what it does: the user learns that from the assistant's message beside it.
162
+ const unexplained = (call: AssistantEvent.ToolCallPart) =>
163
+ AssistantEvent.ToolResult.make({
164
+ callId: call.id,
165
+ tool: call.name,
166
+ ...Toolkits.failed(
167
+ `Not sent for approval: the user would see buttons without knowing what they approve. In one step, send them a message with send_user_message saying what ${call.name} will do (what, to whom, how many), and call ${call.name} again.`,
168
+ "ApprovalNeedsMessage"
169
+ )
170
+ })
171
+
172
+ // Every `ToolStarted` is stored before any call runs; a call that waits for approval doesn't start.
116
173
  const runCalls = <R, S, E, R2>(
117
174
  tools: Toolkits.Tools<R>,
118
- calls: ReadonlyArray<AssistantEvent.ToolCallPart>,
119
- then: (failed: boolean) => Stream.Stream<Step<never, S>, E, R2>
175
+ pending: ReadonlyArray<Pending>,
176
+ history: ReadonlyArray<Prompt.Message>,
177
+ then: (ran: Ran) => Stream.Stream<Step<never, S>, E, R2>
120
178
  ): 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
- })
179
+ Stream.unwrap(
180
+ Effect.gen(function* () {
181
+ const waits = yield* Effect.forEach(pending, ({ call, approved }) =>
182
+ approved ? Effect.succeed(false) : tools.needsApproval(call, history)
131
183
  )
132
- )
184
+
185
+ const started = pending.flatMap(({ call }, i) =>
186
+ waits[i] === true ? [] : [value(AssistantEvent.ToolStarted.make({ callId: call.id, tool: call.name }))]
187
+ )
188
+
189
+ return Stream.fromIterable(started).pipe(
190
+ Stream.concat(
191
+ Stream.unwrap(
192
+ Effect.gen(function* () {
193
+ const explained = pending.some(({ call }) => isUserMessage(call))
194
+
195
+ const done = yield* Effect.forEach(
196
+ pending,
197
+ ({ call }, i): Effect.Effect<Event, never, R> =>
198
+ waits[i] !== true
199
+ ? runCall(tools, call)
200
+ : explained
201
+ ? awaitApproval(call)
202
+ : Effect.succeed(unexplained(call)),
203
+ { concurrency: "unbounded" }
204
+ )
205
+
206
+ return Stream.fromIterable(done.map(value)).pipe(
207
+ Stream.concat(
208
+ then({
209
+ failed: done.some(isFailure),
210
+ waiting: done.some((event) => Predicate.isTagged(event, "ApprovalRequested"))
211
+ })
212
+ )
213
+ )
214
+ })
215
+ )
216
+ )
217
+ )
218
+ })
133
219
  )
134
220
 
135
- const isFailure = (result: AssistantEvent.ToolResult) => result.outcome === "failure"
221
+ const isFailure = (event: Event) => Predicate.isTagged(event, "ToolResult") && event.outcome === "failure"
136
222
 
137
223
  /** @internal */
138
224
  export type Step<A, S> = Data.TaggedEnum<{
@@ -192,19 +278,31 @@ export const step = <R, S, E2 = never, R2 = never>(
192
278
  const instructions = Predicate.isString(options.instructions) ? { text: options.instructions } : options.instructions
193
279
 
194
280
  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
281
 
199
282
  return Stream.unwrap(
200
283
  Effect.gen(function* () {
201
284
  const { messages: history, throughSeq } = readHistory(yield* options.history)
202
285
  // Calls a crash cut off, with no result: they run again, with the same ids, before the model goes on.
203
286
  const cutOff = History.unansweredCalls(history)
287
+ const approvals = yield* TurnApprovals
288
+
289
+ // Calls the user approved: they run as the model made them, with the same ids, before the model goes on. Not in
290
+ // a loop without their tools.
291
+ const approved = Option.isNone(approvals)
292
+ ? []
293
+ : (yield* approvals.value.approved).filter(
294
+ (call) =>
295
+ toolkit.definitions.some((tool) => tool.name === call.name) && !cutOff.some((cut) => cut.id === call.id)
296
+ )
297
+
298
+ if (cutOff.length > 0 || approved.length > 0) {
299
+ const pending = [
300
+ ...cutOff.map((call) => ({ call, approved: false })),
301
+ ...approved.map((call) => ({ call, approved: true }))
302
+ ]
204
303
 
205
- if (cutOff.length > 0) {
206
- return runCalls(toolkit, cutOff, () =>
207
- after({ output: undefined, done: false, historyLength: history.length + cutOff.length })
304
+ return runCalls(toolkit, pending, history, ({ waiting }) =>
305
+ after({ output: undefined, done: waiting, historyLength: history.length + pending.length })
208
306
  )
209
307
  }
210
308
 
@@ -230,15 +328,20 @@ export const step = <R, S, E2 = never, R2 = never>(
230
328
  const calls = toolCalls(output.parts)
231
329
  const historyLength = history.length + Prompt.fromResponseParts(output.parts).content.length + calls.length
232
330
 
233
- // A failed call doesn't end the turn: a message that failed never reached the user, so the model reads why.
331
+ // A failed call doesn't end the turn: a message that failed never reached the user, so the model reads why. A call
332
+ // that waits for approval does: the turn waits for the user.
234
333
  return Stream.make(value(output)).pipe(
235
334
  Stream.concat(
236
- runCalls(toolkit, calls, (failed) =>
237
- after({
238
- output,
239
- done: calls.length === 0 || (!failed && calls.some(isTurnEnding)),
240
- historyLength
241
- })
335
+ runCalls(
336
+ toolkit,
337
+ calls.map((call) => ({ call, approved: false })),
338
+ history,
339
+ ({ failed, waiting }) =>
340
+ after({
341
+ output,
342
+ done: calls.length === 0 || waiting || (!failed && calls.some(isTurnEnding)),
343
+ historyLength
344
+ })
242
345
  )
243
346
  )
244
347
  )
@@ -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,21 +1,27 @@
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
- import type * as Context from "effect/Context"
5
+ import * 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"
10
12
  import { AssistantToolCall } from "../AssistantToolCall.ts"
11
13
  import * as ToolFailure from "../ToolFailure.ts"
14
+ import { ModelView } from "../ToolResult.ts"
12
15
  import { toJson } from "./schema.ts"
16
+ import * as Uuid from "./uuid.ts"
13
17
 
14
18
  /** @internal */
15
19
  export interface CallResult {
16
20
  readonly outcome: AssistantEvent.ToolOutcome
17
21
  readonly result: Schema.Json
18
22
  readonly reason?: string | undefined
23
+ readonly full?: Schema.Json | undefined
24
+ readonly resultId?: string | undefined
19
25
  }
20
26
 
21
27
  // Apps give the loop native toolkits; these are what the loop makes of them, merged with its own.
@@ -23,13 +29,18 @@ export interface CallResult {
23
29
  export interface Tools<out R = never> {
24
30
  readonly definitions: ReadonlyArray<Tool.Any>
25
31
  readonly run: (call: AssistantEvent.ToolCallPart) => Effect.Effect<CallResult, never, R>
32
+ // Whether the call waits for the user's approval instead of running: its tool's `needsApproval`.
33
+ readonly needsApproval: (
34
+ call: AssistantEvent.ToolCallPart,
35
+ history: ReadonlyArray<Prompt.Message>
36
+ ) => Effect.Effect<boolean, never, R>
26
37
  }
27
38
 
28
39
  /** @internal */
29
- export type ToolServices<T extends Record<string, Tool.Any>> = Exclude<
30
- Tool.HandlerServices<T[keyof T]>,
31
- AssistantToolCall
32
- >
40
+ export type ToolServices<T extends Record<string, Tool.Any>> =
41
+ | Exclude<Tool.HandlerServices<T[keyof T]>, AssistantToolCall>
42
+ // Already in the handlers' services; stated for the loop, which decodes params to ask `needsApproval`.
43
+ | T[keyof T]["parametersSchema"]["DecodingServices"]
33
44
 
34
45
  const encodeFailure = Schema.encodeSync(ToolFailure.ToolFailure)
35
46
 
@@ -60,15 +71,32 @@ const hasTool = <T extends Record<string, Tool.Any>>(
60
71
  name: PropertyKey
61
72
  ): name is keyof T => Object.hasOwn(toolkit.tools, name)
62
73
 
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
74
  /** @internal */
66
75
  export const needingApproval = (definitions: ReadonlyArray<Tool.Any>): ReadonlyArray<string> =>
67
76
  definitions.flatMap((tool) => (tool.needsApproval === undefined || tool.needsApproval === false ? [] : [tool.name]))
68
77
 
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.`
78
+ // With noUncheckedIndexedAccess, an index doesn't narrow on `hasTool`.
79
+ const toolNamed = (toolkit: { readonly tools: Record<string, Tool.Any> }, name: string): Tool.Any | undefined =>
80
+ Object.hasOwn(toolkit.tools, name) ? toolkit.tools[name] : undefined
81
+
82
+ // Params that don't fit the tool don't wait: the call runs, and fails with why.
83
+ const needsApprovalNative = Effect.fnUntraced(function* <T extends Record<string, Tool.Any>>(
84
+ toolkit: { readonly tools: T },
85
+ call: AssistantEvent.ToolCallPart,
86
+ history: ReadonlyArray<Prompt.Message>
87
+ ): Effect.fn.Return<boolean, never, ToolServices<T>> {
88
+ const tool = toolNamed(toolkit, call.name)
89
+ const needs = tool?.needsApproval
90
+
91
+ if (tool === undefined || needs === undefined || Predicate.isBoolean(needs)) return needs === true
92
+
93
+ return yield* Effect.gen(function* () {
94
+ const params = yield* Schema.decodeUnknownEffect(tool.parametersSchema)(call.params)
95
+ const decided = needs(params, { toolCallId: call.id, messages: history })
96
+
97
+ return Effect.isEffect(decided) ? yield* decided : decided
98
+ }).pipe(Effect.orElseSucceed(() => false))
99
+ })
72
100
 
73
101
  // The toolkit decodes the params with the tool's schema before its handler sees them, and a call whose params don't
74
102
  // fit fails with a validation error the model reads: the model's JSON is what `handle` takes.
@@ -95,12 +123,22 @@ export const runNative = Effect.fnUntraced(function* <T extends Record<string, T
95
123
  const results = yield* toolkit.handle<keyof T>(name, params, call.id)
96
124
  const last = yield* Stream.runLast(Stream.filter(results, (result) => !result.preliminary))
97
125
 
126
+ const view = Option.flatMap(Option.fromUndefinedOr(toolNamed(toolkit, call.name)), (tool) =>
127
+ Context.getOption(tool.annotations, ModelView)
128
+ )
129
+
98
130
  return Option.match(last, {
99
131
  onNone: () => failed(`${call.name} returned no result.`, "NoResult"),
100
- onSome: (result): CallResult =>
101
- result.isFailure
102
- ? failure(toJson(result.encodedResult))
103
- : { outcome: "success", result: toJson(result.encodedResult) }
132
+ onSome: (result): CallResult => {
133
+ if (result.isFailure) return failure(toJson(result.encodedResult))
134
+
135
+ const full = toJson(result.encodedResult)
136
+
137
+ return Option.match(view, {
138
+ onNone: () => ({ outcome: "success", result: full }),
139
+ onSome: (read) => ({ outcome: "success", result: toJson(read(result.result)), full })
140
+ })
141
+ }
104
142
  })
105
143
  }).pipe(
106
144
  Effect.catch((error) =>
@@ -117,7 +155,8 @@ export const fromWithHandler = <T extends Record<string, Tool.Any>>(
117
155
  toolkit: Toolkit.WithHandler<T>
118
156
  ): Tools<ToolServices<T>> => ({
119
157
  definitions: Object.values(toolkit.tools),
120
- run: (call) => runNative(toolkit, call)
158
+ run: (call) => runNative(toolkit, call),
159
+ needsApproval: (call, history) => needsApprovalNative(toolkit, call, history)
121
160
  })
122
161
 
123
162
  /** @internal */
@@ -127,22 +166,8 @@ export const fromToolkit = <T extends Record<string, Tool.Any>>(
127
166
  definitions: Object.values(toolkit.tools),
128
167
  run: Effect.fnUntraced(function* (call) {
129
168
  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
- })
169
+ }),
170
+ needsApproval: (call, history) => needsApprovalNative(toolkit, call, history)
146
171
  })
147
172
 
148
173
  /** @internal */
@@ -153,7 +178,8 @@ export const fromToolkitOrEmpty = <T extends Record<string, Tool.Any>>(
153
178
  /** @internal */
154
179
  export const empty: Tools = {
155
180
  definitions: [],
156
- run: (call) => Effect.succeed(failed(`No tool named ${call.name}.`, "ToolNotFoundError"))
181
+ run: (call) => Effect.succeed(failed(`No tool named ${call.name}.`, "ToolNotFoundError")),
182
+ needsApproval: () => Effect.succeed(false)
157
183
  }
158
184
 
159
185
  /** @internal */
@@ -162,15 +188,54 @@ export const merge = <A, B>(self: Tools<A>, that: Tools<B>): Tools<A | B> => {
162
188
 
163
189
  return {
164
190
  definitions: [...self.definitions, ...that.definitions],
165
- run: (call) => (inSelf(call) ? self.run(call) : that.run(call))
191
+ run: (call) => (inSelf(call) ? self.run(call) : that.run(call)),
192
+ needsApproval: (call, history) =>
193
+ inSelf(call) ? self.needsApproval(call, history) : that.needsApproval(call, history)
194
+ }
195
+ }
196
+
197
+ // Short, for the model to copy, and from the call: a call run again after a crash keeps its id.
198
+ /** @internal */
199
+ export const resultIdOf = (callId: string): string => `r-${Uuid.fromKey(`result:${callId}`).slice(0, 8)}`
200
+
201
+ const decodeJsonObject = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Json))
202
+
203
+ const withResultId = (result: CallResult, resultId: string): CallResult => {
204
+ if (result.outcome === "failure") return result
205
+
206
+ const read = result.result
207
+ const fields = Array.isArray(read) ? Option.none() : decodeJsonObject(read)
208
+
209
+ return {
210
+ ...result,
211
+ result: Option.match(fields, {
212
+ onNone: () => ({ resultId, result: read }),
213
+ onSome: (object) => ({ resultId, ...object })
214
+ }),
215
+ full: result.full ?? read,
216
+ resultId
166
217
  }
167
218
  }
168
219
 
220
+ // The results of tools the assistant's blocks show get an id the model passes to show one.
221
+ /** @internal */
222
+ export const showing = <R>(tools: Tools<R>, names: ReadonlySet<string>): Tools<R> =>
223
+ names.size === 0
224
+ ? tools
225
+ : {
226
+ ...tools,
227
+ run: (call) =>
228
+ names.has(call.name)
229
+ ? Effect.map(tools.run(call), (result) => withResultId(result, resultIdOf(call.id)))
230
+ : tools.run(call)
231
+ }
232
+
169
233
  // A turn's own services, for a loop the app writes.
170
234
  /** @internal */
171
235
  export const provide = <R>(tools: Tools<R>, context: Context.Context<R>): Tools => ({
172
236
  definitions: tools.definitions,
173
- run: (call) => Effect.provideContext(tools.run(call), context)
237
+ run: (call) => Effect.provideContext(tools.run(call), context),
238
+ needsApproval: (call, history) => Effect.provideContext(tools.needsApproval(call, history), context)
174
239
  })
175
240
 
176
241
  /** @internal */
@@ -184,5 +249,7 @@ export const duplicateNames = (tools: Tools<unknown>): ReadonlyArray<string> =>
184
249
  /** @internal */
185
250
  export const runOnly = <R>(tools: Tools<R>, names: ReadonlyArray<string>, message: string): Tools<R> => ({
186
251
  definitions: tools.definitions,
187
- run: (call) => (names.includes(call.name) ? tools.run(call) : Effect.succeed(failed(message, "NotNow")))
252
+ run: (call) => (names.includes(call.name) ? tools.run(call) : Effect.succeed(failed(message, "NotNow"))),
253
+ // Refused like any other call it doesn't run.
254
+ needsApproval: () => Effect.succeed(false)
188
255
  })
@@ -6,6 +6,7 @@ import * as Predicate from "effect/Predicate"
6
6
  import * as Schema from "effect/Schema"
7
7
  import * as AssistantEvent from "../AssistantEvent.ts"
8
8
  import { AssistantToolCall } from "../AssistantToolCall.ts"
9
+ import * as Block from "../Block.ts"
9
10
  import type * as BlockKit from "../BlockKit.ts"
10
11
  import { CurrentChat } from "../CurrentChat.ts"
11
12
  import * as ToolFailure from "../ToolFailure.ts"
@@ -34,6 +35,13 @@ const WaitFlag = Schema.Boolean.annotate({
34
35
 
35
36
  const endsWithMessage = Schema.decodeUnknownOption(Schema.Struct({ wait: Schema.Literal(true) }))
36
37
 
38
+ const messageText = Schema.decodeUnknownOption(Schema.Struct({ text: Schema.String }))
39
+
40
+ // A message the user reads: the call's text isn't empty.
41
+ /** @internal */
42
+ export const isUserMessage = (call: AssistantEvent.ToolCallPart): boolean =>
43
+ call.name === sendUserMessageName && Option.exists(messageText(call.params), ({ text }) => text.trim() !== "")
44
+
37
45
  /** @internal */
38
46
  export const isTurnEnding = (call: AssistantEvent.ToolCallPart): boolean =>
39
47
  call.name === waitToolName || (call.name === sendUserMessageName && Option.isSome(endsWithMessage(call.params)))
@@ -76,9 +84,56 @@ const plainTools = Effect.gen(function* () {
76
84
  return yield* Effect.provideContext(PlainTools, yield* plainHandlers)
77
85
  })
78
86
 
87
+ /** @internal */
88
+ export const toolResultKind = "tool_result"
89
+
90
+ const ToolResultRef = Schema.Struct({
91
+ resultId: Schema.String.annotate({ description: "The resultId of the tool result to show." })
92
+ })
93
+
94
+ const decodeRef = Schema.decodeUnknownOption(ToolResultRef)
95
+
96
+ // The latest successful result with that id, of a tool the kit shows.
97
+ const shownResult = Effect.fnUntraced(function* (resultId: string, shown: ReadonlySet<string>) {
98
+ const entries = yield* (yield* CurrentChat).entries
99
+
100
+ const result = entries
101
+ .flatMap(({ event }) =>
102
+ AssistantEvent.isLibraryEvent(event) && Predicate.isTagged(event, "ToolResult") && event.resultId === resultId
103
+ ? [event]
104
+ : []
105
+ )
106
+ .at(-1)
107
+
108
+ if (result === undefined || result.outcome === "failure") {
109
+ return yield* ToolFailure.fail(
110
+ `No tool result ${resultId} to show: pass the resultId of a result you got in this chat.`,
111
+ { reason: "ResultNotFound" }
112
+ )
113
+ }
114
+
115
+ if (!shown.has(result.tool)) {
116
+ return yield* ToolFailure.fail(`${result.tool} results can't be shown.`, { reason: "ResultNotShown" })
117
+ }
118
+
119
+ return result
120
+ })
121
+
79
122
  const blockTools = Effect.fnUntraced(function* (kit: BlockKit.BlockKit) {
80
- const catalog = kit.blocks.map((block) => `- ${block.kind}: ${block.description}`).join("\n")
81
- const kinds = kit.blocks.map((block) => block.kind)
123
+ const asked = kit.blocks.filter((block) => !Block.isToolResult(block))
124
+ const shown = new Set(kit.blocks.flatMap((block) => (Block.isToolResult(block) ? [block.tool.name] : [])))
125
+
126
+ const catalog = [
127
+ ...asked.map((block) => `- ${block.kind}: ${block.description}`),
128
+ ...(shown.size === 0
129
+ ? []
130
+ : [
131
+ `- ${toolResultKind}: a tool's result, shown the way the app draws it (found items as cards, a download as a button). Results you can show have a resultId: pass it as the props. From: ${[...shown].join(", ")}.`
132
+ ])
133
+ ].join("\n")
134
+
135
+ const kinds = [...asked.map((block) => block.kind), ...(shown.size === 0 ? [] : [toolResultKind])]
136
+ const props = [...asked.map((block) => block.props), ...(shown.size === 0 ? [] : [ToolResultRef])]
82
137
 
83
138
  const BlockMessage = Tool.make(sendUserMessageName, {
84
139
  description: `${description}
@@ -92,7 +147,7 @@ ${catalog}`,
92
147
  block: Schema.optional(
93
148
  Schema.Struct({
94
149
  kind: Schema.Literals(kinds),
95
- props: Schema.Union(kit.blocks.map((block) => block.props))
150
+ props: Schema.Union(props)
96
151
  })
97
152
  ),
98
153
  closes: Schema.optional(Schema.Array(Schema.String)).annotate({
@@ -126,9 +181,30 @@ ${catalog}`,
126
181
  return "sent"
127
182
  }
128
183
 
129
- const props = kit.byKind.get(block.kind)?.props
184
+ if (block.kind === toolResultKind) {
185
+ const ref = decodeRef(block.props)
186
+
187
+ if (Option.isNone(ref)) {
188
+ return yield* ToolFailure.fail(`A ${toolResultKind} block's props are { resultId }.`)
189
+ }
190
+
191
+ const result = yield* shownResult(ref.value.resultId, shown)
192
+
193
+ yield* append(
194
+ AssistantEvent.AssistantMessage.make({
195
+ id,
196
+ text,
197
+ block: AssistantEvent.MessageBlock.make({ kind: result.tool, props: {}, resultId: ref.value.resultId }),
198
+ ...closed
199
+ })
200
+ )
201
+
202
+ return `sent as message ${id}`
203
+ }
204
+
205
+ const schema = kit.byKind.get(block.kind)?.props
130
206
 
131
- if (props === undefined || Option.isNone(Schema.decodeUnknownOption(props)(block.props))) {
207
+ if (schema === undefined || Option.isNone(Schema.decodeUnknownOption(schema)(block.props))) {
132
208
  return yield* ToolFailure.fail(`Those props don't fit a ${block.kind} block.`)
133
209
  }
134
210