effect-assistant 0.4.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 (46) hide show
  1. package/dist/AssistantEvent.d.ts +11 -0
  2. package/dist/AssistantEvent.d.ts.map +1 -1
  3. package/dist/AssistantEvent.js +13 -2
  4. package/dist/AssistantEvent.js.map +1 -1
  5. package/dist/Block.d.ts +63 -1
  6. package/dist/Block.d.ts.map +1 -1
  7. package/dist/Block.js +63 -2
  8. package/dist/Block.js.map +1 -1
  9. package/dist/BlockKit.d.ts +25 -3
  10. package/dist/BlockKit.d.ts.map +1 -1
  11. package/dist/BlockKit.js +14 -12
  12. package/dist/BlockKit.js.map +1 -1
  13. package/dist/Compaction.js +4 -4
  14. package/dist/Timeline.d.ts +4 -0
  15. package/dist/Timeline.d.ts.map +1 -1
  16. package/dist/Timeline.js +19 -7
  17. package/dist/Timeline.js.map +1 -1
  18. package/dist/ToolResult.d.ts +56 -0
  19. package/dist/ToolResult.d.ts.map +1 -0
  20. package/dist/ToolResult.js +49 -0
  21. package/dist/ToolResult.js.map +1 -0
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.js +4 -3
  24. package/dist/internal/loop.d.ts.map +1 -1
  25. package/dist/internal/loop.js +8 -2
  26. package/dist/internal/loop.js.map +1 -1
  27. package/dist/internal/toolkit.d.ts +7 -1
  28. package/dist/internal/toolkit.d.ts.map +1 -1
  29. package/dist/internal/toolkit.js +47 -4
  30. package/dist/internal/toolkit.js.map +1 -1
  31. package/dist/internal/tools.js +39 -6
  32. package/dist/internal/tools.js.map +1 -1
  33. package/dist/internal/turns.d.ts.map +1 -1
  34. package/dist/internal/turns.js +6 -3
  35. package/dist/internal/turns.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/AssistantEvent.ts +13 -2
  38. package/src/Block.ts +82 -1
  39. package/src/BlockKit.ts +37 -4
  40. package/src/Timeline.ts +16 -3
  41. package/src/ToolResult.ts +80 -0
  42. package/src/index.ts +5 -0
  43. package/src/internal/loop.ts +19 -2
  44. package/src/internal/toolkit.ts +55 -5
  45. package/src/internal/tools.ts +81 -5
  46. package/src/internal/turns.ts +7 -3
@@ -197,12 +197,16 @@ export type UserInput = typeof UserInput.Type
197
197
  * Represents the UI shown with a message, as a block kind from the assistant's `BlockKit` and its
198
198
  * props.
199
199
  *
200
+ * A block that shows a tool's result (`Block.fromToolResult`) has the tool's name as its kind and
201
+ * the result's `resultId`.
202
+ *
200
203
  * @category schemas
201
204
  * @since 0.1.0
202
205
  */
203
206
  export class MessageBlock extends Schema.Class<MessageBlock>("effect-assistant/MessageBlock")({
204
207
  kind: Schema.String,
205
- props: Schema.Unknown
208
+ props: Schema.Unknown,
209
+ resultId: Schema.optional(Schema.String)
206
210
  }) {}
207
211
 
208
212
  /**
@@ -325,6 +329,10 @@ export type ToolOutcome = typeof ToolOutcome.Type
325
329
  * failure includes its reason when it has one, which is a `ToolFailure`'s `reason` or the
326
330
  * failure's tag.
327
331
  *
332
+ * When the model reads less than the whole success (a `ToolResult.modelView`, or the `resultId`
333
+ * added for a tool the assistant's blocks show), `full` holds the whole success, encoded by the
334
+ * tool's success schema.
335
+ *
328
336
  * @category schemas
329
337
  * @since 0.1.0
330
338
  */
@@ -333,7 +341,10 @@ export class ToolResult extends Schema.TaggedClass<ToolResult>()("ToolResult", {
333
341
  tool: Schema.String,
334
342
  outcome: ToolOutcome,
335
343
  result: Schema.Json,
336
- reason: Schema.optional(Schema.String)
344
+ reason: Schema.optional(Schema.String),
345
+ full: Schema.optional(Schema.Json),
346
+ /** The id the model passes in a `tool_result` block to show this result. */
347
+ resultId: Schema.optional(Schema.String)
337
348
  }) {}
338
349
 
339
350
  /**
package/src/Block.ts CHANGED
@@ -5,9 +5,14 @@
5
5
  * pick from, a yes or no, or a small input. The assistant fills in its `props`, and
6
6
  * using the block sends back a value typed by its `answer` schema.
7
7
  *
8
+ * A block made with {@link fromToolResult} shows a tool's result instead: the assistant picks the
9
+ * result, and the client draws it from what the tool returned.
10
+ *
8
11
  * @since 0.1.0
9
12
  */
10
- import type * as Schema from "effect/Schema"
13
+ import type * as Tool from "effect/ai/Tool"
14
+ import * as Predicate from "effect/Predicate"
15
+ import * as Schema from "effect/Schema"
11
16
 
12
17
  /**
13
18
  * Schema that a block's props or answer can use.
@@ -82,3 +87,79 @@ export const make = <const Kind extends string, Props extends Codec, Answer exte
82
87
  props: options.props,
83
88
  answer: options.answer
84
89
  })
90
+
91
+ /**
92
+ * Schema for the props of a block that shows a tool's result: none, since the result is the block.
93
+ *
94
+ * @category schemas
95
+ * @since 0.5.0
96
+ */
97
+ export const NoProps = Schema.Struct({})
98
+
99
+ /**
100
+ * Represents a block that shows a tool's results, made with {@link fromToolResult}. Its kind is the
101
+ * tool's name.
102
+ *
103
+ * @category models
104
+ * @since 0.5.0
105
+ */
106
+ export interface ToolResultBlock<T extends Tool.Any = Tool.Any> extends Block<
107
+ Tool.Name<T>,
108
+ typeof NoProps,
109
+ Schema.Never
110
+ > {
111
+ readonly tool: T
112
+ /** The tool's success schema, which the client decodes the result with. */
113
+ readonly result: Codec
114
+ }
115
+
116
+ /**
117
+ * Creates a block that shows a tool's results.
118
+ *
119
+ * **Details**
120
+ *
121
+ * Put it in the assistant's `BlockKit`. The tool's results then carry a `resultId` in what the model
122
+ * reads, and the model shows one with a `tool_result` block that names it: it never sees the
123
+ * block's kind or how the client draws it. Sending the block fails back to the model when no
124
+ * successful result of a shown tool has that id.
125
+ *
126
+ * On the client, `BlockKit.toClient` gives the block's renderer the result, decoded by the tool's
127
+ * success schema. The tool's definition is shared with the client for that, so it can't need
128
+ * server-only services: add those on the server with `Tool.addDependency`.
129
+ *
130
+ * **Example** (Showing search results as cards)
131
+ *
132
+ * ```ts
133
+ * import { Block, BlockKit } from "effect-assistant"
134
+ * import { SearchAssets } from "./tools.ts"
135
+ *
136
+ * export const Blocks = BlockKit.make([PickOne, Block.fromToolResult(SearchAssets)])
137
+ *
138
+ * // Client
139
+ * const renderBlock = BlockKit.toClient<typeof Blocks, ReactNode>(Blocks, {
140
+ * pick_one: ({ props, respond }) => <Options options={props.options} onPick={respond} />,
141
+ * search_assets: ({ result }) => <AssetCards assets={result.items} />
142
+ * })
143
+ * ```
144
+ *
145
+ * @category constructors
146
+ * @since 0.5.0
147
+ */
148
+ export const fromToolResult = <T extends Tool.Any>(
149
+ tool: T & { readonly successSchema: Codec }
150
+ ): ToolResultBlock<T> => ({
151
+ kind: tool.name,
152
+ description: `Shows a ${tool.name} result.`,
153
+ props: NoProps,
154
+ answer: undefined,
155
+ tool,
156
+ result: tool.successSchema
157
+ })
158
+
159
+ /**
160
+ * Returns whether a block shows a tool's results.
161
+ *
162
+ * @category guards
163
+ * @since 0.5.0
164
+ */
165
+ export const isToolResult = (block: Any): block is ToolResultBlock => Predicate.hasProperty(block, "tool")
package/src/BlockKit.ts CHANGED
@@ -8,12 +8,13 @@
8
8
  *
9
9
  * @since 0.1.0
10
10
  */
11
+ import type * as Tool from "effect/ai/Tool"
11
12
  import * as Effect from "effect/Effect"
12
13
  import * as Option from "effect/Option"
13
14
  import * as Predicate from "effect/Predicate"
14
15
  import * as Schema from "effect/Schema"
15
16
  import * as AssistantEvent from "./AssistantEvent.ts"
16
- import type * as Block from "./Block.ts"
17
+ import * as Block from "./Block.ts"
17
18
  import type * as Timeline from "./Timeline.ts"
18
19
  import { CurrentChat } from "./CurrentChat.ts"
19
20
  import * as InputId from "./InputId.ts"
@@ -40,6 +41,15 @@ export const make = <const Blocks extends ReadonlyArray<Block.Any>>(blocks: Bloc
40
41
  byKind: new Map(blocks.map((block) => [block.kind, block]))
41
42
  })
42
43
 
44
+ /**
45
+ * Returns the names of the tools whose results the kit's blocks show (`Block.fromToolResult`).
46
+ *
47
+ * @category utils
48
+ * @since 0.5.0
49
+ */
50
+ export const toolNames = (kit: BlockKit | undefined): ReadonlySet<string> =>
51
+ new Set((kit?.blocks ?? []).flatMap((block) => (Block.isToolResult(block) ? [block.tool.name] : [])))
52
+
43
53
  type KindOf<B> = B extends { readonly kind: infer K extends string } ? K : never
44
54
 
45
55
  type BlockOf<Kit extends BlockKit, K> = Extract<Kit["blocks"][number], { readonly kind: K }>
@@ -48,6 +58,13 @@ type PropsOf<B> = B extends { readonly props: Schema.Top } ? B["props"]["Type"]
48
58
 
49
59
  type AnswerOf<B> = B extends { readonly answer: infer A } ? (A extends Schema.Top ? A["Type"] : never) : never
50
60
 
61
+ type ResultArgs<B> = B extends { readonly tool: infer T }
62
+ ? {
63
+ /** The tool's result the block shows, decoded by its success schema. */
64
+ readonly result: Tool.Success<T>
65
+ }
66
+ : unknown
67
+
51
68
  /**
52
69
  * Represents the state of a message's block.
53
70
  *
@@ -164,12 +181,21 @@ export const closeStale = Effect.fn(function* (options: { readonly messages: num
164
181
  })
165
182
 
166
183
  /**
167
- * Represents what a renderer receives for one block.
184
+ * Represents what a renderer receives for one block. A block that shows a tool's result also gets
185
+ * the `result`.
168
186
  *
169
187
  * @category models
170
188
  * @since 0.1.0
171
189
  */
172
- export interface RenderArgs<B> {
190
+ export type RenderArgs<B> = BlockArgs<B> & ResultArgs<B>
191
+
192
+ /**
193
+ * Represents what every block's renderer receives.
194
+ *
195
+ * @category models
196
+ * @since 0.5.0
197
+ */
198
+ export interface BlockArgs<B> {
173
199
  /**
174
200
  * The id of the message the block is on. A block whose button calls your API can key the work
175
201
  * on it, with the chat's id, so the work runs once per block.
@@ -256,7 +282,7 @@ export function toClient<Kit extends BlockKit, Out>(
256
282
  // arguments.
257
283
  export function toClient<Out>(
258
284
  kit: BlockKit,
259
- renderers: Readonly<Record<string, (args: RenderArgs<Block.Any>) => Out>>
285
+ renderers: Readonly<Record<string, (args: BlockArgs<Block.Any> & { readonly result?: unknown }) => Out>>
260
286
  ): (message: Timeline.AssistantMessage, options: RenderOptions) => Out | undefined {
261
287
  return (message, options) => {
262
288
  if (message.block === undefined) return undefined
@@ -271,6 +297,12 @@ export function toClient<Out>(
271
297
 
272
298
  if (Option.isNone(props)) return undefined
273
299
 
300
+ const result = Block.isToolResult(block)
301
+ ? Schema.decodeUnknownOption(Schema.toCodecJson(block.result))(message.block.result)
302
+ : Option.some(undefined)
303
+
304
+ if (Option.isNone(result)) return undefined
305
+
274
306
  const given = message.block.answer
275
307
  const answerCodec = block.answer === undefined ? undefined : Schema.toCodecJson(block.answer)
276
308
 
@@ -281,6 +313,7 @@ export function toClient<Out>(
281
313
  messageId: message.id,
282
314
  text: message.text,
283
315
  props: props.value,
316
+ result: result.value,
284
317
  open: message.block.status === "open",
285
318
  answer: Option.getOrUndefined(answer),
286
319
  respond: (value) =>
package/src/Timeline.ts CHANGED
@@ -64,7 +64,9 @@ export const Block = Schema.Struct({
64
64
  kind: Schema.String,
65
65
  props: Schema.Json,
66
66
  status: BlockStatus,
67
- answer: Schema.optional(Schema.Json)
67
+ answer: Schema.optional(Schema.Json),
68
+ /** For a block that shows a tool's result (`Block.fromToolResult`): the result, as the tool's success schema encodes it. */
69
+ result: Schema.optional(Schema.Json)
68
70
  })
69
71
 
70
72
  /**
@@ -628,6 +630,8 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
628
630
  // The items in order, and the assistant's messages as stored: their blocks change as the chat goes on.
629
631
  const items = new Map<string, AnyItem>()
630
632
  const sent = new Map<string, AssistantEvent.AssistantMessage>()
633
+ // The results a block can show, by id: a message shows one stored before it.
634
+ const results = new Map<string, Schema.Json>()
631
635
 
632
636
  const blockOf = (message: AssistantEvent.AssistantMessage): Block | undefined => {
633
637
  if (message.block === undefined) return undefined
@@ -635,8 +639,11 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
635
639
  const state = blocks.get(message.id)
636
640
  const status = state === undefined || state.open ? "open" : state.answer === undefined ? "closed" : "answered"
637
641
  const block: Block = { kind: message.block.kind, props: toJson(message.block.props), status }
642
+ const { resultId } = message.block
643
+ const result = resultId === undefined ? undefined : results.get(resultId)
644
+ const shown = result === undefined ? block : { ...block, result }
638
645
 
639
- return state?.answer === undefined ? block : { ...block, answer: toJson(state.answer.value) }
646
+ return state?.answer === undefined ? shown : { ...shown, answer: toJson(state.answer.value) }
640
647
  }
641
648
 
642
649
  const itemOf = (message: AssistantEvent.AssistantMessage, at: number): AssistantMessage => {
@@ -693,7 +700,13 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
693
700
  },
694
701
  ModelOutput: () => [],
695
702
  ToolStarted: () => [],
696
- ToolResult: () => [],
703
+ ToolResult: (result) => {
704
+ if (result.resultId !== undefined && result.outcome === "success") {
705
+ results.set(result.resultId, result.full ?? result.result)
706
+ }
707
+
708
+ return []
709
+ },
697
710
  TurnStarted: () => [],
698
711
  TurnCompleted: () => [],
699
712
  SpecialistStarted: () => [],
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Shapes what the model reads of a tool's result.
3
+ *
4
+ * A tool's success goes into the chat's log in full. {@link modelView} gives the model a smaller
5
+ * view of it, so fields only the app's UI needs (an image URL, a file id) stay out of its context
6
+ * and out of what it might copy into its replies.
7
+ *
8
+ * A tool whose results the assistant's blocks show (`Block.fromToolResult`) also gets a `resultId`
9
+ * in what the model reads. The model passes it in a `tool_result` block to show that result, and
10
+ * the client draws it from the full result in the log.
11
+ *
12
+ * @since 0.5.0
13
+ */
14
+ import type * as Tool from "effect/ai/Tool"
15
+ import * as Context from "effect/Context"
16
+ import { dual } from "effect/Function"
17
+ import type * as Schema from "effect/Schema"
18
+
19
+ // The tool's schemas, as `Tool.Tool` constrains them.
20
+ type Schemas = {
21
+ readonly parameters: Schema.Top
22
+ readonly success: Schema.Top
23
+ readonly failure: Schema.Top
24
+ readonly failureMode: Tool.FailureMode
25
+ }
26
+
27
+ /**
28
+ * Annotation for what the model reads of a tool's success, set with {@link modelView}.
29
+ *
30
+ * @category annotations
31
+ * @since 0.5.0
32
+ */
33
+ export class ModelView extends Context.Service<ModelView, (success: any) => Schema.Json>()(
34
+ "effect-assistant/ToolResult/ModelView"
35
+ ) {}
36
+
37
+ /**
38
+ * Sets what the model reads of a tool's success. The chat's log keeps the whole success.
39
+ *
40
+ * **Details**
41
+ *
42
+ * The view gets the success as the tool's success schema decodes it, and returns the JSON the
43
+ * model reads instead. Failures reach the model as they are.
44
+ *
45
+ * **Example** (Keeping image URLs out of the model's context)
46
+ *
47
+ * ```ts
48
+ * import * as Tool from "effect/ai/Tool"
49
+ * import * as Schema from "effect/Schema"
50
+ * import { ToolResult } from "effect-assistant"
51
+ *
52
+ * const Asset = Schema.Struct({ id: Schema.String, name: Schema.String, imageUrl: Schema.String })
53
+ *
54
+ * export const SearchAssets = ToolResult.modelView(
55
+ * Tool.make("search_assets", {
56
+ * parameters: Schema.Struct({ query: Schema.String }),
57
+ * success: Schema.Struct({ items: Schema.Array(Asset) })
58
+ * }),
59
+ * (result) => ({ items: result.items.map(({ id, name }) => ({ id, name })) })
60
+ * )
61
+ * ```
62
+ *
63
+ * @category annotations
64
+ * @since 0.5.0
65
+ */
66
+ export const modelView: {
67
+ <Config extends Schemas>(
68
+ view: (success: Config["success"]["Type"]) => Schema.Json
69
+ ): <Name extends string, R>(tool: Tool.Tool<Name, Config, R>) => Tool.Tool<Name, Config, R>
70
+ <Name extends string, Config extends Schemas, R>(
71
+ tool: Tool.Tool<Name, Config, R>,
72
+ view: (success: Config["success"]["Type"]) => Schema.Json
73
+ ): Tool.Tool<Name, Config, R>
74
+ } = dual(
75
+ 2,
76
+ <Name extends string, Config extends Schemas, R>(
77
+ tool: Tool.Tool<Name, Config, R>,
78
+ view: (success: Config["success"]["Type"]) => Schema.Json
79
+ ): Tool.Tool<Name, Config, R> => tool.annotate(ModelView, view)
80
+ )
package/src/index.ts CHANGED
@@ -118,3 +118,8 @@ export * as ToolDiscovery from "./ToolDiscovery.ts"
118
118
  * @since 0.1.0
119
119
  */
120
120
  export * as ToolFailure from "./ToolFailure.ts"
121
+
122
+ /**
123
+ * @since 0.5.0
124
+ */
125
+ export * as ToolResult from "./ToolResult.ts"
@@ -18,7 +18,7 @@ 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
24
  export type Event =
@@ -158,6 +158,17 @@ interface Ran {
158
158
  readonly waiting: boolean
159
159
  }
160
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
+
161
172
  // Every `ToolStarted` is stored before any call runs; a call that waits for approval doesn't start.
162
173
  const runCalls = <R, S, E, R2>(
163
174
  tools: Toolkits.Tools<R>,
@@ -179,10 +190,16 @@ const runCalls = <R, S, E, R2>(
179
190
  Stream.concat(
180
191
  Stream.unwrap(
181
192
  Effect.gen(function* () {
193
+ const explained = pending.some(({ call }) => isUserMessage(call))
194
+
182
195
  const done = yield* Effect.forEach(
183
196
  pending,
184
197
  ({ call }, i): Effect.Effect<Event, never, R> =>
185
- waits[i] === true ? awaitApproval(call) : runCall(tools, call),
198
+ waits[i] !== true
199
+ ? runCall(tools, call)
200
+ : explained
201
+ ? awaitApproval(call)
202
+ : Effect.succeed(unexplained(call)),
186
203
  { concurrency: "unbounded" }
187
204
  )
188
205
 
@@ -2,7 +2,7 @@ import * as AiError from "effect/ai/AiError"
2
2
  import type * as Prompt from "effect/ai/Prompt"
3
3
  import type * as Tool from "effect/ai/Tool"
4
4
  import type * as Toolkit from "effect/ai/Toolkit"
5
- import type * as Context from "effect/Context"
5
+ import * as Context from "effect/Context"
6
6
  import * as Effect from "effect/Effect"
7
7
  import * as Option from "effect/Option"
8
8
  import * as Predicate from "effect/Predicate"
@@ -11,13 +11,17 @@ import * as Stream from "effect/Stream"
11
11
  import type * as AssistantEvent from "../AssistantEvent.ts"
12
12
  import { AssistantToolCall } from "../AssistantToolCall.ts"
13
13
  import * as ToolFailure from "../ToolFailure.ts"
14
+ import { ModelView } from "../ToolResult.ts"
14
15
  import { toJson } from "./schema.ts"
16
+ import * as Uuid from "./uuid.ts"
15
17
 
16
18
  /** @internal */
17
19
  export interface CallResult {
18
20
  readonly outcome: AssistantEvent.ToolOutcome
19
21
  readonly result: Schema.Json
20
22
  readonly reason?: string | undefined
23
+ readonly full?: Schema.Json | undefined
24
+ readonly resultId?: string | undefined
21
25
  }
22
26
 
23
27
  // Apps give the loop native toolkits; these are what the loop makes of them, merged with its own.
@@ -119,12 +123,22 @@ export const runNative = Effect.fnUntraced(function* <T extends Record<string, T
119
123
  const results = yield* toolkit.handle<keyof T>(name, params, call.id)
120
124
  const last = yield* Stream.runLast(Stream.filter(results, (result) => !result.preliminary))
121
125
 
126
+ const view = Option.flatMap(Option.fromUndefinedOr(toolNamed(toolkit, call.name)), (tool) =>
127
+ Context.getOption(tool.annotations, ModelView)
128
+ )
129
+
122
130
  return Option.match(last, {
123
131
  onNone: () => failed(`${call.name} returned no result.`, "NoResult"),
124
- onSome: (result): CallResult =>
125
- result.isFailure
126
- ? failure(toJson(result.encodedResult))
127
- : { 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
+ }
128
142
  })
129
143
  }).pipe(
130
144
  Effect.catch((error) =>
@@ -180,6 +194,42 @@ export const merge = <A, B>(self: Tools<A>, that: Tools<B>): Tools<A | B> => {
180
194
  }
181
195
  }
182
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
217
+ }
218
+ }
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
+
183
233
  // A turn's own services, for a loop the app writes.
184
234
  /** @internal */
185
235
  export const provide = <R>(tools: Tools<R>, context: Context.Context<R>): Tools => ({
@@ -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
 
@@ -17,7 +17,7 @@ import type { ChatId } from "../ChatId.ts"
17
17
  import * as AssistantEvent from "../AssistantEvent.ts"
18
18
  import type * as AssistantLoop from "../AssistantLoop.ts"
19
19
  import * as Loop from "./loop.ts"
20
- import type * as BlockKit from "../BlockKit.ts"
20
+ import * as BlockKit from "../BlockKit.ts"
21
21
  import type { CurrentChat } from "../CurrentChat.ts"
22
22
  import * as Instructions from "../Instructions.ts"
23
23
  import * as ToolApproval from "../ToolApproval.ts"
@@ -214,6 +214,7 @@ const makeTurnApi = (
214
214
  ): Assistant.Turn<any> => {
215
215
  const { definition, builtins } = registration
216
216
  const { app } = definition
217
+ const shownTools = BlockKit.toolNames(definition.blocks)
217
218
 
218
219
  const running = <A, E, R>(stream: Stream.Stream<A, E, R>) =>
219
220
  Stream.unwrap(Ref.set(ranLoop, true).pipe(Effect.as(stream)))
@@ -280,7 +281,10 @@ const makeTurnApi = (
280
281
  }
281
282
  }),
282
283
  // A second step, for a reply after a call it was refused.
283
- maxSteps: 2
284
+ maxSteps: 2,
285
+ // The pass runs because the model wrote text of its own instead of replying, which reaches nobody: it has to
286
+ // call a tool now. Changing the choice costs a read of the history at full price, on these rare turns only.
287
+ toolChoice: "required"
284
288
  // The loop ran the approved calls; a pass that can only reply runs none.
285
289
  }).pipe(Stream.provideService(Loop.TurnApprovals, Option.none()))
286
290
  })
@@ -297,7 +301,7 @@ const makeTurnApi = (
297
301
  )
298
302
 
299
303
  const prepare = <T extends Record<string, Tool.Any>>(options: Assistant.LoopOptions<T, any>) => {
300
- const own = Toolkits.fromToolkitOrEmpty(options.toolkit)
304
+ const own = Toolkits.showing(Toolkits.fromToolkitOrEmpty(options.toolkit), shownTools)
301
305
 
302
306
  return {
303
307
  system: instructions(options.instructions),