effect-assistant 0.4.0 → 0.6.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 (102) hide show
  1. package/dist/Assistant.d.ts +10 -10
  2. package/dist/Assistant.d.ts.map +1 -1
  3. package/dist/Assistant.js +1 -1
  4. package/dist/Assistant.js.map +1 -1
  5. package/dist/AssistantActivity.d.ts +2 -2
  6. package/dist/AssistantActivity.d.ts.map +1 -1
  7. package/dist/AssistantActivity.js +5 -5
  8. package/dist/AssistantActivity.js.map +1 -1
  9. package/dist/AssistantEngine.d.ts +3 -3
  10. package/dist/AssistantEngine.js +2 -2
  11. package/dist/AssistantEngine.js.map +1 -1
  12. package/dist/AssistantEvent.d.ts +19 -8
  13. package/dist/AssistantEvent.d.ts.map +1 -1
  14. package/dist/AssistantEvent.js +20 -9
  15. package/dist/AssistantEvent.js.map +1 -1
  16. package/dist/AssistantLoop.d.ts +2 -2
  17. package/dist/AssistantLoop.js.map +1 -1
  18. package/dist/Block.d.ts +63 -1
  19. package/dist/Block.d.ts.map +1 -1
  20. package/dist/Block.js +63 -2
  21. package/dist/Block.js.map +1 -1
  22. package/dist/BlockKit.d.ts +25 -3
  23. package/dist/BlockKit.d.ts.map +1 -1
  24. package/dist/BlockKit.js +14 -12
  25. package/dist/BlockKit.js.map +1 -1
  26. package/dist/ChatLog.d.ts +9 -9
  27. package/dist/ChatLog.d.ts.map +1 -1
  28. package/dist/ChatLog.js +21 -21
  29. package/dist/ChatLog.js.map +1 -1
  30. package/dist/Compaction.d.ts +10 -10
  31. package/dist/Compaction.d.ts.map +1 -1
  32. package/dist/Compaction.js +19 -19
  33. package/dist/Compaction.js.map +1 -1
  34. package/dist/Memory.d.ts +1 -1
  35. package/dist/Memory.js +1 -1
  36. package/dist/Memory.js.map +1 -1
  37. package/dist/Skill.d.ts +1 -1
  38. package/dist/Skill.js +1 -1
  39. package/dist/Skill.js.map +1 -1
  40. package/dist/Specialist.d.ts +1 -1
  41. package/dist/Specialist.js +1 -1
  42. package/dist/Specialist.js.map +1 -1
  43. package/dist/Timeline.d.ts +13 -9
  44. package/dist/Timeline.d.ts.map +1 -1
  45. package/dist/Timeline.js +35 -23
  46. package/dist/Timeline.js.map +1 -1
  47. package/dist/ToolApproval.d.ts +1 -1
  48. package/dist/ToolApproval.js +1 -1
  49. package/dist/ToolApproval.js.map +1 -1
  50. package/dist/ToolDiscovery.d.ts +1 -1
  51. package/dist/ToolDiscovery.js +1 -1
  52. package/dist/ToolDiscovery.js.map +1 -1
  53. package/dist/ToolResult.d.ts +56 -0
  54. package/dist/ToolResult.d.ts.map +1 -0
  55. package/dist/ToolResult.js +49 -0
  56. package/dist/ToolResult.js.map +1 -0
  57. package/dist/index.d.ts +2 -1
  58. package/dist/index.js +4 -3
  59. package/dist/internal/chat.js +4 -4
  60. package/dist/internal/chat.js.map +1 -1
  61. package/dist/internal/events.js +1 -1
  62. package/dist/internal/events.js.map +1 -1
  63. package/dist/internal/history.js +23 -23
  64. package/dist/internal/history.js.map +1 -1
  65. package/dist/internal/loop.d.ts +1 -1
  66. package/dist/internal/loop.d.ts.map +1 -1
  67. package/dist/internal/loop.js +12 -6
  68. package/dist/internal/loop.js.map +1 -1
  69. package/dist/internal/toolkit.d.ts +7 -1
  70. package/dist/internal/toolkit.d.ts.map +1 -1
  71. package/dist/internal/toolkit.js +47 -4
  72. package/dist/internal/toolkit.js.map +1 -1
  73. package/dist/internal/tools.js +39 -6
  74. package/dist/internal/tools.js.map +1 -1
  75. package/dist/internal/turns.d.ts.map +1 -1
  76. package/dist/internal/turns.js +24 -21
  77. package/dist/internal/turns.js.map +1 -1
  78. package/package.json +1 -1
  79. package/src/Assistant.ts +11 -11
  80. package/src/AssistantActivity.ts +9 -7
  81. package/src/AssistantEngine.ts +3 -3
  82. package/src/AssistantEvent.ts +21 -10
  83. package/src/AssistantLoop.ts +3 -3
  84. package/src/Block.ts +82 -1
  85. package/src/BlockKit.ts +37 -4
  86. package/src/ChatLog.ts +31 -26
  87. package/src/Compaction.ts +21 -21
  88. package/src/Memory.ts +1 -1
  89. package/src/Skill.ts +1 -1
  90. package/src/Specialist.ts +1 -1
  91. package/src/Timeline.ts +38 -25
  92. package/src/ToolApproval.ts +1 -1
  93. package/src/ToolDiscovery.ts +1 -1
  94. package/src/ToolResult.ts +80 -0
  95. package/src/index.ts +5 -0
  96. package/src/internal/chat.ts +4 -4
  97. package/src/internal/events.ts +1 -1
  98. package/src/internal/history.ts +32 -28
  99. package/src/internal/loop.ts +24 -7
  100. package/src/internal/toolkit.ts +55 -5
  101. package/src/internal/tools.ts +81 -5
  102. package/src/internal/turns.ts +35 -29
@@ -140,7 +140,7 @@ const awaitingMessage = (request: AssistantEvent.ApprovalRequested): Prompt.Tool
140
140
  })
141
141
 
142
142
  const isEntry = (item: AssistantEvent.AnyEntry | Prompt.Message): item is AssistantEvent.AnyEntry =>
143
- Predicate.hasProperty(item, "seq")
143
+ Predicate.hasProperty(item, "sequence")
144
144
 
145
145
  // In the order the model saw the entries, not the order they were stored. A message a history view put in stands
146
146
  // where it is, right after the entry before it, and waits like an input for a call's result or for the model's next
@@ -166,15 +166,15 @@ export const fromEntries = (
166
166
  const next = entries.slice(from + 1).find(({ event }) => Predicate.isTagged(event, "ModelOutput"))?.event
167
167
 
168
168
  return next !== undefined && AssistantEvent.isLibraryEvent(next) && Predicate.isTagged(next, "ModelOutput")
169
- ? next.seenThroughSeq
169
+ ? next.seenThroughSequence
170
170
  : undefined
171
171
  }
172
172
 
173
- // `index` is the last entry at or before the item, and `seq` where the item stands.
174
- const input = (index: number, seq: number, item: Prompt.Message) => {
173
+ // `index` is the last entry at or before the item, and `sequence` where the item stands.
174
+ const input = (index: number, sequence: number, item: Prompt.Message) => {
175
175
  const seen = nextSeen(index)
176
176
 
177
- if (open.size > 0 || (seen !== undefined && seen < seq)) held.push(item)
177
+ if (open.size > 0 || (seen !== undefined && seen < sequence)) held.push(item)
178
178
  else history.push(item)
179
179
  }
180
180
 
@@ -182,14 +182,14 @@ export const fromEntries = (
182
182
 
183
183
  for (const item of items) {
184
184
  if (!isEntry(item)) {
185
- input(index, (entries[index]?.seq ?? 0) + 0.5, item)
185
+ input(index, (entries[index]?.sequence ?? 0) + 0.5, item)
186
186
 
187
187
  continue
188
188
  }
189
189
 
190
190
  index += 1
191
191
 
192
- const { event, seq } = item
192
+ const { event, sequence } = item
193
193
  const at = index
194
194
 
195
195
  if (!AssistantEvent.isLibraryEvent(event)) continue
@@ -197,8 +197,8 @@ export const fromEntries = (
197
197
  Match.value(event).pipe(
198
198
  Match.tagsExhaustive({
199
199
  UserMessage: (message) =>
200
- input(at, seq, userText(AssistantEvent.textWithAttachments(message.text, message.attachments))),
201
- BlockAnswer: (answer) => input(at, seq, userText(answerText(answer))),
200
+ input(at, sequence, userText(AssistantEvent.textWithAttachments(message.text, message.attachments))),
201
+ BlockAnswer: (answer) => input(at, sequence, userText(answerText(answer))),
202
202
  ModelOutput: (output) => {
203
203
  history.push(...Prompt.fromResponseParts(output.parts).content)
204
204
 
@@ -212,11 +212,11 @@ export const fromEntries = (
212
212
  flushIfClosed()
213
213
  },
214
214
  ToolResult: (result) => {
215
- if (requests.has(result.callId)) return input(at, seq, userText(approvedText(result)))
215
+ if (requests.has(result.callId)) return input(at, sequence, userText(approvedText(result)))
216
216
 
217
217
  // A view that starts after the call (since a summary, say) keeps its result as text: a tool result needs its
218
218
  // call right before it.
219
- if (!called.has(result.callId)) return input(at, seq, userText(resultText(result)))
219
+ if (!called.has(result.callId)) return input(at, sequence, userText(resultText(result)))
220
220
 
221
221
  history.push(toolMessage(result))
222
222
  open.delete(result.callId)
@@ -227,7 +227,7 @@ export const fromEntries = (
227
227
  TurnStarted: () => undefined,
228
228
  TurnCompleted: () => undefined,
229
229
  TurnFailed: () => undefined,
230
- SpecialistFinished: (finished) => input(at, seq, userText(finishedText(entries, at, finished))),
230
+ SpecialistFinished: (finished) => input(at, sequence, userText(finishedText(entries, at, finished))),
231
231
  // Every run starts in a call of the model's own (a spawn or a follow-up), whose result says so.
232
232
  SpecialistStarted: () => undefined,
233
233
  BlocksClosed: () => undefined,
@@ -235,7 +235,7 @@ export const fromEntries = (
235
235
  ApprovalRequested: (request) => {
236
236
  requests.set(request.callId, request)
237
237
 
238
- if (!called.has(request.callId)) return input(at, seq, userText(awaitingText(request)))
238
+ if (!called.has(request.callId)) return input(at, sequence, userText(awaitingText(request)))
239
239
 
240
240
  history.push(awaitingMessage(request))
241
241
  open.delete(request.callId)
@@ -244,10 +244,10 @@ export const fromEntries = (
244
244
  // An approved call's result says so.
245
245
  ApprovalDecision: (decision) =>
246
246
  decision.decision === "rejected"
247
- ? input(at, seq, userText(rejectedText(decision, toolOf(decision.callId))))
247
+ ? input(at, sequence, userText(rejectedText(decision, toolOf(decision.callId))))
248
248
  : undefined,
249
249
  ApprovalsExpired: (expired) =>
250
- expired.callIds.forEach((callId) => input(at, seq, userText(expiredText(callId, toolOf(callId))))),
250
+ expired.callIds.forEach((callId) => input(at, sequence, userText(expiredText(callId, toolOf(callId))))),
251
251
  ChatDeleted: () => undefined
252
252
  })
253
253
  )
@@ -264,7 +264,7 @@ export const isInput = (event: { readonly _tag: string }): boolean =>
264
264
  export const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): number => {
265
265
  const last = entries.findLast(({ event }) => AssistantEvent.isTurnEnd(event))?.event
266
266
 
267
- return last !== undefined && AssistantEvent.isTurnEnd(last) ? last.throughSeq : 0
267
+ return last !== undefined && AssistantEvent.isTurnEnd(last) ? last.throughSequence : 0
268
268
  }
269
269
 
270
270
  // A run the assistant stopped isn't an input here: it wakes nothing.
@@ -274,8 +274,10 @@ export const unhandledInputs = (
274
274
  ): ReadonlyArray<{ readonly _tag: string }> => {
275
275
  const seen = handledThrough(entries)
276
276
 
277
- return entries.flatMap(({ event, seq }) =>
278
- seq > seen && isInput(event) && !(AssistantEvent.isInput(event) && !AssistantEvent.isWaking(event)) ? [event] : []
277
+ return entries.flatMap(({ event, sequence }) =>
278
+ sequence > seen && isInput(event) && !(AssistantEvent.isInput(event) && !AssistantEvent.isWaking(event))
279
+ ? [event]
280
+ : []
279
281
  )
280
282
  }
281
283
 
@@ -285,16 +287,17 @@ export const needsTurn = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): bool
285
287
 
286
288
  // A failure the user was shown counts as a reply, and so does a call they're asked to approve.
287
289
  /** @internal */
288
- export const owesReply = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSeq: number): boolean => {
290
+ export const owesReply = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSequence: number): boolean => {
289
291
  const latest = entries.findLast(
290
- ({ event, seq }) => seq <= throughSeq && AssistantEvent.isInput(event) && AssistantEvent.isUserInput(event)
292
+ ({ event, sequence }) =>
293
+ sequence <= throughSequence && AssistantEvent.isInput(event) && AssistantEvent.isUserInput(event)
291
294
  )
292
295
 
293
296
  return (
294
297
  latest !== undefined &&
295
298
  !entries.some(
296
- ({ event, seq }) =>
297
- seq > latest.seq &&
299
+ ({ event, sequence }) =>
300
+ sequence > latest.sequence &&
298
301
  (Predicate.isTagged(event, "AssistantMessage") ||
299
302
  Predicate.isTagged(event, "TurnFailed") ||
300
303
  Predicate.isTagged(event, "ApprovalRequested"))
@@ -321,15 +324,16 @@ const progressMessages = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): Read
321
324
  // The model said it was on it and stopped without the result: only progress messages since the user's latest input.
322
325
  // A specialist still working doesn't count, since its report is what comes next.
323
326
  /** @internal */
324
- export const owesDelivery = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSeq: number): boolean => {
327
+ export const owesDelivery = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSequence: number): boolean => {
325
328
  const latest = entries.findLast(
326
- ({ event, seq }) => seq <= throughSeq && AssistantEvent.isInput(event) && AssistantEvent.isUserInput(event)
329
+ ({ event, sequence }) =>
330
+ sequence <= throughSequence && AssistantEvent.isInput(event) && AssistantEvent.isUserInput(event)
327
331
  )
328
332
 
329
333
  if (latest === undefined) return false
330
334
 
331
- const after = entries.flatMap(({ seq, event }) =>
332
- seq > latest.seq && AssistantEvent.isLibraryEvent(event) ? [event] : []
335
+ const after = entries.flatMap(({ sequence, event }) =>
336
+ sequence > latest.sequence && AssistantEvent.isLibraryEvent(event) ? [event] : []
333
337
  )
334
338
 
335
339
  const messages = after.flatMap((event) => (Predicate.isTagged(event, "AssistantMessage") ? [event] : []))
@@ -352,5 +356,5 @@ export const owesDelivery = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, th
352
356
  }
353
357
 
354
358
  /** @internal */
355
- export const unreadAfter = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSeq: number): boolean =>
356
- entries.some(({ event, seq }) => seq > throughSeq && isInput(event))
359
+ export const unreadAfter = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSequence: number): boolean =>
360
+ entries.some(({ event, sequence }) => sequence > throughSequence && isInput(event))
@@ -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 =
@@ -44,13 +44,13 @@ export const TurnTools = Context.Reference<Option.Option<Toolkits.Tools>>("effec
44
44
  /** @internal */
45
45
  export interface ChatHistory {
46
46
  readonly messages: ReadonlyArray<Prompt.Message>
47
- readonly throughSeq: number
47
+ readonly throughSequence: number
48
48
  }
49
49
 
50
50
  const readHistory = (read: ReadonlyArray<Prompt.Message> | ChatHistory) =>
51
51
  "messages" in read
52
- ? { messages: read.messages, throughSeq: read.throughSeq }
53
- : { messages: read, throughSeq: undefined }
52
+ ? { messages: read.messages, throughSequence: read.throughSequence }
53
+ : { messages: read, throughSequence: undefined }
54
54
 
55
55
  // `AssistantLoop` takes native toolkits; turns and specialist runs come here with their tools already merged.
56
56
  /** @internal */
@@ -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
 
@@ -264,7 +281,7 @@ export const step = <R, S, E2 = never, R2 = never>(
264
281
 
265
282
  return Stream.unwrap(
266
283
  Effect.gen(function* () {
267
- const { messages: history, throughSeq } = readHistory(yield* options.history)
284
+ const { messages: history, throughSequence } = readHistory(yield* options.history)
268
285
  // Calls a crash cut off, with no result: they run again, with the same ids, before the model goes on.
269
286
  const cutOff = History.unansweredCalls(history)
270
287
  const approvals = yield* TurnApprovals
@@ -301,7 +318,7 @@ export const step = <R, S, E2 = never, R2 = never>(
301
318
 
302
319
  const output = AssistantEvent.ModelOutput.make({
303
320
  parts: modelParts(response.content),
304
- seenThroughSeq: throughSeq,
321
+ seenThroughSequence: throughSequence,
305
322
  model: metadata?.modelId ?? Option.getOrUndefined(name),
306
323
  responseId: metadata?.id,
307
324
  usage: response.usage,
@@ -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"
@@ -140,12 +140,13 @@ const approvedCalls = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): Readonl
140
140
  : []
141
141
  )
142
142
 
143
- const lastSeq = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, otherwise: number) => entries.at(-1)?.seq ?? otherwise
143
+ const lastSequence = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, otherwise: number) =>
144
+ entries.at(-1)?.sequence ?? otherwise
144
145
 
145
146
  // The handler's stream can end between a step's output and its tools, or while they run. An interrupted turn (a
146
147
  // shutdown) leaves its calls open instead: the next turn runs them again.
147
- const closeOpenCalls = Effect.fnUntraced(function* (chat: Writer, startSeq: number) {
148
- const entries = (yield* chat.entries).filter(({ seq }) => seq > startSeq)
148
+ const closeOpenCalls = Effect.fnUntraced(function* (chat: Writer, startSequence: number) {
149
+ const entries = (yield* chat.entries).filter(({ sequence }) => sequence > startSequence)
149
150
 
150
151
  const started = new Set(
151
152
  entries.flatMap(({ event }) => (Predicate.isTagged(event, "ToolStarted") ? [event.callId] : []))
@@ -169,18 +170,18 @@ const closeOpenCalls = Effect.fnUntraced(function* (chat: Writer, startSeq: numb
169
170
  )
170
171
  })
171
172
 
172
- // The inputs before the turn's `TurnStarted` (at `startSeq`) woke it; past that, what its model outputs record
173
+ // The inputs before the turn's `TurnStarted` (at `startSequence`) woke it; past that, what its model outputs record
173
174
  // reading.
174
- const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, startSeq: number) =>
175
+ const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, startSequence: number) =>
175
176
  entries.reduce(
176
- (through, { seq, event }) =>
177
- seq > startSeq &&
177
+ (through, { sequence, event }) =>
178
+ sequence > startSequence &&
178
179
  AssistantEvent.isLibraryEvent(event) &&
179
180
  Predicate.isTagged(event, "ModelOutput") &&
180
- event.seenThroughSeq !== undefined
181
- ? Math.max(through, event.seenThroughSeq)
181
+ event.seenThroughSequence !== undefined
182
+ ? Math.max(through, event.seenThroughSequence)
182
183
  : through,
183
- startSeq
184
+ startSequence
184
185
  )
185
186
 
186
187
  // A turn that ran the loop fails visibly when the user's latest input got no reply; an input that arrived after what
@@ -190,8 +191,8 @@ const finishTurn = Effect.fnUntraced(function* (chat: Writer, read: number, ranL
190
191
 
191
192
  const end =
192
193
  ranLoop && History.owesReply(entries, read) && !History.unreadAfter(entries, read)
193
- ? AssistantEvent.TurnFailed.make({ throughSeq: read, message: silentTurnMessage })
194
- : AssistantEvent.TurnCompleted.make({ throughSeq: read })
194
+ ? AssistantEvent.TurnFailed.make({ throughSequence: read, message: silentTurnMessage })
195
+ : AssistantEvent.TurnCompleted.make({ throughSequence: read })
195
196
 
196
197
  yield* chat.append([end])
197
198
  })
@@ -200,20 +201,21 @@ const finishTurn = Effect.fnUntraced(function* (chat: Writer, read: number, ranL
200
201
  const failTurn = Effect.fnUntraced(function* (chat: Writer, cause: Cause.Cause<unknown>) {
201
202
  yield* Effect.logError("assistant turn failed", cause)
202
203
 
203
- const last = lastSeq(yield* chat.entries, 0)
204
+ const last = lastSequence(yield* chat.entries, 0)
204
205
 
205
- yield* chat.append([AssistantEvent.TurnFailed.make({ throughSeq: last, message: failureMessage(cause) })])
206
+ yield* chat.append([AssistantEvent.TurnFailed.make({ throughSequence: last, message: failureMessage(cause) })])
206
207
  })
207
208
 
208
209
  const makeTurnApi = (
209
210
  registration: Registration,
210
211
  chat: Writer,
211
- startSeq: number,
212
+ startSequence: number,
212
213
  checkOpen: Effect.Effect<void>,
213
214
  ranLoop: Ref.Ref<boolean>
214
215
  ): Assistant.Turn<any> => {
215
216
  const { definition, builtins } = registration
216
217
  const { app } = definition
218
+ const shownTools = BlockKit.toolNames(definition.blocks)
217
219
 
218
220
  const running = <A, E, R>(stream: Stream.Stream<A, E, R>) =>
219
221
  Stream.unwrap(Ref.set(ranLoop, true).pipe(Effect.as(stream)))
@@ -227,7 +229,7 @@ const makeTurnApi = (
227
229
 
228
230
  const read: AssistantLoop.ChatHistory = {
229
231
  messages: History.fromEntries(view(AppEvents.decodeEntries(app, all))),
230
- throughSeq: lastSeq(all, startSeq)
232
+ throughSequence: lastSequence(all, startSequence)
231
233
  }
232
234
 
233
235
  return read
@@ -246,7 +248,7 @@ const makeTurnApi = (
246
248
  Stream.unwrap(
247
249
  Effect.gen(function* () {
248
250
  const all = yield* chat.entries
249
- const through = handledThrough(all, startSeq)
251
+ const through = handledThrough(all, startSequence)
250
252
 
251
253
  // Something arrived after what the loop's model read: the next turn reads it with the full loop, and replies.
252
254
  if (History.unreadAfter(all, through)) return Stream.empty
@@ -276,11 +278,14 @@ const makeTurnApi = (
276
278
  ...History.fromEntries(options.view(AppEvents.decodeEntries(app, entries))),
277
279
  History.userText(nudge)
278
280
  ],
279
- throughSeq: lastSeq(entries, startSeq)
281
+ throughSequence: lastSequence(entries, startSequence)
280
282
  }
281
283
  }),
282
284
  // A second step, for a reply after a call it was refused.
283
- maxSteps: 2
285
+ maxSteps: 2,
286
+ // The pass runs because the model wrote text of its own instead of replying, which reaches nobody: it has to
287
+ // call a tool now. Changing the choice costs a read of the history at full price, on these rare turns only.
288
+ toolChoice: "required"
284
289
  // The loop ran the approved calls; a pass that can only reply runs none.
285
290
  }).pipe(Stream.provideService(Loop.TurnApprovals, Option.none()))
286
291
  })
@@ -296,8 +301,8 @@ const makeTurnApi = (
296
301
  })
297
302
  )
298
303
 
299
- const prepare = <T extends Record<string, Tool.Any>>(options: Assistant.LoopOptions<T, any>) => {
300
- const own = Toolkits.fromToolkitOrEmpty(options.toolkit)
304
+ const prepare = <T extends Record<string, Tool.Any>>(options: Assistant.RunOptions<T, any>) => {
305
+ const own = Toolkits.showing(Toolkits.fromToolkitOrEmpty(options.toolkit), shownTools)
301
306
 
302
307
  return {
303
308
  system: instructions(options.instructions),
@@ -311,7 +316,7 @@ const makeTurnApi = (
311
316
  history,
312
317
  instructions,
313
318
  replyIfOwed,
314
- loop: (options) => {
319
+ run: (options) => {
315
320
  const { system, toolkit, view } = prepare(options)
316
321
  const twice = Toolkits.duplicateNames(toolkit)
317
322
 
@@ -358,15 +363,15 @@ export const run = Effect.fnUntraced(
358
363
  )
359
364
  ])
360
365
 
361
- const startSeq = lastSeq(started, 0)
366
+ const startSequence = lastSequence(started, 0)
362
367
  const ranLoop = yield* Ref.make(false)
363
- const turn = makeTurnApi(options.registration, chat, startSeq, options.checkOpen, ranLoop)
368
+ const turn = makeTurnApi(options.registration, chat, startSequence, options.checkOpen, ranLoop)
364
369
 
365
370
  const record = (event: AssistantLoop.Event) => chat.append([event])
366
371
 
367
372
  yield* options.handler(turn).pipe(
368
373
  Stream.runForEach(record),
369
- Effect.onExit((exit) => (Exit.hasInterrupts(exit) ? Effect.void : closeOpenCalls(chat, startSeq))),
374
+ Effect.onExit((exit) => (Exit.hasInterrupts(exit) ? Effect.void : closeOpenCalls(chat, startSequence))),
370
375
  // A loop the app writes gets the assistant's own tools too, with this turn's services.
371
376
  Effect.provideService(
372
377
  Loop.TurnTools,
@@ -379,15 +384,16 @@ export const run = Effect.fnUntraced(
379
384
  onSuccess: Effect.fnUntraced(function* () {
380
385
  const all = yield* chat.entries
381
386
 
382
- yield* finishTurn(chat, handledThrough(all, startSeq), yield* Ref.get(ranLoop))
387
+ yield* finishTurn(chat, handledThrough(all, startSequence), yield* Ref.get(ranLoop))
383
388
  }),
384
389
  onFailure: (cause) => failTurn(chat, cause)
385
390
  })
386
391
  )
387
392
 
388
393
  // The trace's output is what the user saw this turn, and a failure if any.
389
- const shown = (yield* chat.entries).flatMap(({ event, seq }) =>
390
- seq > startSeq && (Predicate.isTagged(event, "AssistantMessage") || Predicate.isTagged(event, "TurnFailed"))
394
+ const shown = (yield* chat.entries).flatMap(({ event, sequence }) =>
395
+ sequence > startSequence &&
396
+ (Predicate.isTagged(event, "AssistantMessage") || Predicate.isTagged(event, "TurnFailed"))
391
397
  ? [event]
392
398
  : []
393
399
  )