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.
- package/dist/AssistantEvent.d.ts +11 -0
- package/dist/AssistantEvent.d.ts.map +1 -1
- package/dist/AssistantEvent.js +13 -2
- package/dist/AssistantEvent.js.map +1 -1
- package/dist/Block.d.ts +63 -1
- package/dist/Block.d.ts.map +1 -1
- package/dist/Block.js +63 -2
- package/dist/Block.js.map +1 -1
- package/dist/BlockKit.d.ts +25 -3
- package/dist/BlockKit.d.ts.map +1 -1
- package/dist/BlockKit.js +14 -12
- package/dist/BlockKit.js.map +1 -1
- package/dist/Compaction.js +4 -4
- package/dist/Timeline.d.ts +4 -0
- package/dist/Timeline.d.ts.map +1 -1
- package/dist/Timeline.js +19 -7
- package/dist/Timeline.js.map +1 -1
- package/dist/ToolResult.d.ts +56 -0
- package/dist/ToolResult.d.ts.map +1 -0
- package/dist/ToolResult.js +49 -0
- package/dist/ToolResult.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -3
- package/dist/internal/loop.d.ts.map +1 -1
- package/dist/internal/loop.js +8 -2
- package/dist/internal/loop.js.map +1 -1
- package/dist/internal/toolkit.d.ts +7 -1
- package/dist/internal/toolkit.d.ts.map +1 -1
- package/dist/internal/toolkit.js +47 -4
- package/dist/internal/toolkit.js.map +1 -1
- package/dist/internal/tools.js +39 -6
- package/dist/internal/tools.js.map +1 -1
- package/dist/internal/turns.d.ts.map +1 -1
- package/dist/internal/turns.js +6 -3
- package/dist/internal/turns.js.map +1 -1
- package/package.json +1 -1
- package/src/AssistantEvent.ts +13 -2
- package/src/Block.ts +82 -1
- package/src/BlockKit.ts +37 -4
- package/src/Timeline.ts +16 -3
- package/src/ToolResult.ts +80 -0
- package/src/index.ts +5 -0
- package/src/internal/loop.ts +19 -2
- package/src/internal/toolkit.ts +55 -5
- package/src/internal/tools.ts +81 -5
- package/src/internal/turns.ts +7 -3
package/src/AssistantEvent.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
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 ?
|
|
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
package/src/internal/loop.ts
CHANGED
|
@@ -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]
|
|
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
|
|
package/src/internal/toolkit.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
127
|
-
|
|
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 => ({
|
package/src/internal/tools.ts
CHANGED
|
@@ -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
|
|
81
|
-
const
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
|
package/src/internal/turns.ts
CHANGED
|
@@ -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
|
|
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),
|