effect-assistant 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/Assistant.d.ts +77 -37
  2. package/dist/Assistant.d.ts.map +1 -1
  3. package/dist/Assistant.js +11 -4
  4. package/dist/Assistant.js.map +1 -1
  5. package/dist/AssistantActivity.d.ts +10 -16
  6. package/dist/AssistantActivity.d.ts.map +1 -1
  7. package/dist/AssistantActivity.js +11 -4
  8. package/dist/AssistantActivity.js.map +1 -1
  9. package/dist/AssistantEngine.d.ts +39 -1
  10. package/dist/AssistantEngine.d.ts.map +1 -1
  11. package/dist/AssistantEngine.js +81 -28
  12. package/dist/AssistantEngine.js.map +1 -1
  13. package/dist/AssistantEvent.d.ts +104 -11
  14. package/dist/AssistantEvent.d.ts.map +1 -1
  15. package/dist/AssistantEvent.js +88 -13
  16. package/dist/AssistantEvent.js.map +1 -1
  17. package/dist/AssistantLoop.d.ts +4 -2
  18. package/dist/AssistantLoop.d.ts.map +1 -1
  19. package/dist/AssistantLoop.js +4 -2
  20. package/dist/AssistantLoop.js.map +1 -1
  21. package/dist/BlockKit.d.ts +4 -4
  22. package/dist/BlockKit.js +1 -1
  23. package/dist/BlockKit.js.map +1 -1
  24. package/dist/ChatLog.js +1 -1
  25. package/dist/Compaction.d.ts +7 -3
  26. package/dist/Compaction.d.ts.map +1 -1
  27. package/dist/Compaction.js +19 -8
  28. package/dist/Compaction.js.map +1 -1
  29. package/dist/CurrentChat.d.ts +2 -1
  30. package/dist/CurrentChat.d.ts.map +1 -1
  31. package/dist/CurrentChat.js.map +1 -1
  32. package/dist/Instructions.js +1 -1
  33. package/dist/Skill.d.ts +86 -11
  34. package/dist/Skill.d.ts.map +1 -1
  35. package/dist/Skill.js +93 -18
  36. package/dist/Skill.js.map +1 -1
  37. package/dist/Specialist.d.ts +7 -7
  38. package/dist/Specialist.d.ts.map +1 -1
  39. package/dist/Specialist.js.map +1 -1
  40. package/dist/Timeline.d.ts +516 -0
  41. package/dist/Timeline.d.ts.map +1 -0
  42. package/dist/Timeline.js +631 -0
  43. package/dist/Timeline.js.map +1 -0
  44. package/dist/ToolApproval.d.ts +146 -0
  45. package/dist/ToolApproval.d.ts.map +1 -0
  46. package/dist/ToolApproval.js +201 -0
  47. package/dist/ToolApproval.js.map +1 -0
  48. package/dist/ToolDiscovery.d.ts +6 -4
  49. package/dist/ToolDiscovery.d.ts.map +1 -1
  50. package/dist/ToolDiscovery.js +5 -4
  51. package/dist/ToolDiscovery.js.map +1 -1
  52. package/dist/index.d.ts +4 -4
  53. package/dist/index.js +4 -4
  54. package/dist/internal/chat.js +9 -7
  55. package/dist/internal/chat.js.map +1 -1
  56. package/dist/internal/events.d.ts +15 -16
  57. package/dist/internal/events.d.ts.map +1 -1
  58. package/dist/internal/events.js +29 -28
  59. package/dist/internal/events.js.map +1 -1
  60. package/dist/internal/history.js +53 -22
  61. package/dist/internal/history.js.map +1 -1
  62. package/dist/internal/input.js +7 -0
  63. package/dist/internal/input.js.map +1 -0
  64. package/dist/internal/loop.d.ts +10 -5
  65. package/dist/internal/loop.d.ts.map +1 -1
  66. package/dist/internal/loop.js +55 -22
  67. package/dist/internal/loop.js.map +1 -1
  68. package/dist/internal/specialists.js +10 -16
  69. package/dist/internal/specialists.js.map +1 -1
  70. package/dist/internal/toolkit.d.ts +5 -7
  71. package/dist/internal/toolkit.d.ts.map +1 -1
  72. package/dist/internal/toolkit.js +28 -18
  73. package/dist/internal/toolkit.js.map +1 -1
  74. package/dist/internal/tools.js +1 -1
  75. package/dist/internal/turns.d.ts.map +1 -1
  76. package/dist/internal/turns.js +35 -42
  77. package/dist/internal/turns.js.map +1 -1
  78. package/package.json +1 -1
  79. package/src/Assistant.ts +80 -40
  80. package/src/AssistantActivity.ts +11 -18
  81. package/src/AssistantEngine.ts +110 -43
  82. package/src/AssistantEvent.ts +106 -14
  83. package/src/AssistantLoop.ts +4 -2
  84. package/src/BlockKit.ts +6 -6
  85. package/src/Compaction.ts +25 -4
  86. package/src/CurrentChat.ts +2 -1
  87. package/src/Skill.ts +133 -23
  88. package/src/Specialist.ts +9 -9
  89. package/src/Timeline.ts +947 -0
  90. package/src/ToolApproval.ts +249 -0
  91. package/src/ToolDiscovery.ts +8 -3
  92. package/src/index.ts +10 -10
  93. package/src/internal/chat.ts +15 -5
  94. package/src/internal/events.ts +43 -43
  95. package/src/internal/history.ts +94 -32
  96. package/src/internal/input.ts +3 -0
  97. package/src/internal/loop.ts +120 -34
  98. package/src/internal/specialists.ts +7 -12
  99. package/src/internal/toolkit.ts +47 -30
  100. package/src/internal/turns.ts +52 -58
  101. package/dist/AppEvent.d.ts +0 -111
  102. package/dist/AppEvent.d.ts.map +0 -1
  103. package/dist/AppEvent.js +0 -71
  104. package/dist/AppEvent.js.map +0 -1
  105. package/dist/ChatView.d.ts +0 -408
  106. package/dist/ChatView.d.ts.map +0 -1
  107. package/dist/ChatView.js +0 -443
  108. package/dist/ChatView.js.map +0 -1
  109. package/dist/internal/skills.js +0 -28
  110. package/dist/internal/skills.js.map +0 -1
  111. package/src/AppEvent.ts +0 -134
  112. package/src/ChatView.ts +0 -587
  113. package/src/internal/skills.ts +0 -37
@@ -110,16 +110,53 @@ const answerText = (answer: AssistantEvent.BlockAnswer) =>
110
110
  const resultText = (result: AssistantEvent.ToolResult) =>
111
111
  `<result tool="${result.tool}" call="${result.callId}" outcome="${result.outcome}">\nThe result of a call from earlier in this chat.\n${toJsonText(result.result)}\n</result>`
112
112
 
113
- // In the order the model saw the entries, not the order they were stored.
113
+ /** @internal */
114
+ export const awaitingApprovalOutput =
115
+ "Waiting for the user's approval: they see this call and approve or reject it. Its outcome arrives later in this chat; don't call it again meanwhile."
116
+
117
+ const awaitingText = (request: AssistantEvent.ApprovalRequested) =>
118
+ `<approval tool="${request.tool}" call="${request.callId}" status="pending">\nYour call to ${request.tool} from earlier in this chat waits for the user's approval.\n${toJsonText(request.params)}\n</approval>`
119
+
120
+ const approvedText = (result: AssistantEvent.ToolResult) =>
121
+ `<result tool="${result.tool}" call="${result.callId}" outcome="${result.outcome}">\nThe user approved your call to ${result.tool} from earlier in this chat, and it ran.\n${toJsonText(result.result)}\n</result>`
122
+
123
+ const rejectedText = (decision: AssistantEvent.ApprovalDecision, tool: string) =>
124
+ `<approval tool="${tool}" call="${decision.callId}" decision="rejected">\nThe user rejected your call to ${tool}; it didn't run.${decision.reason === undefined ? "" : `\nTheir reason: ${decision.reason}`}\n</approval>`
125
+
126
+ const expiredText = (callId: string, tool: string) =>
127
+ `<approval tool="${tool}" call="${callId}" status="expired">\nThe user moved on without approving your call to ${tool}; it didn't run.\n</approval>`
128
+
129
+ const awaitingMessage = (request: AssistantEvent.ApprovalRequested): Prompt.ToolMessage =>
130
+ Prompt.toolMessage({
131
+ content: [
132
+ Prompt.toolResultPart({
133
+ id: request.callId,
134
+ name: request.tool,
135
+ isFailure: false,
136
+ result: awaitingApprovalOutput,
137
+ providerExecuted: false
138
+ })
139
+ ]
140
+ })
141
+
142
+ const isEntry = (item: AssistantEvent.AnyEntry | Prompt.Message): item is AssistantEvent.AnyEntry =>
143
+ Predicate.hasProperty(item, "seq")
144
+
145
+ // In the order the model saw the entries, not the order they were stored. A message a history view put in stands
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
147
+ // read; the app's own events reach the model only as such messages.
114
148
  /** @internal */
115
149
  export const fromEntries = (
116
- entries: ReadonlyArray<AssistantEvent.AnyEntry>,
117
- readApp: (event: { readonly _tag: string }) => string | undefined
150
+ items: ReadonlyArray<AssistantEvent.AnyEntry | Prompt.Message>
118
151
  ): ReadonlyArray<Prompt.Message> => {
152
+ const entries = items.filter(isEntry)
119
153
  const history: Array<Prompt.Message> = []
120
154
  const held: Array<Prompt.Message> = []
121
155
  const open = new Set<string>()
122
156
  const called = new Set<string>()
157
+ // Calls that waited for approval: the placeholder answered them, so what came of them later is an input.
158
+ const requests = new Map<string, AssistantEvent.ApprovalRequested>()
159
+ const toolOf = (callId: string) => requests.get(callId)?.tool ?? "a tool"
123
160
 
124
161
  const flushIfClosed = () => {
125
162
  if (open.size === 0) history.push(...held.splice(0))
@@ -133,27 +170,35 @@ export const fromEntries = (
133
170
  : undefined
134
171
  }
135
172
 
136
- const input = (index: number, item: Prompt.Message) => {
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) => {
137
175
  const seen = nextSeen(index)
138
176
 
139
- if (open.size > 0 || (seen !== undefined && seen < entries[index]!.seq)) held.push(item)
177
+ if (open.size > 0 || (seen !== undefined && seen < seq)) held.push(item)
140
178
  else history.push(item)
141
179
  }
142
180
 
143
- entries.forEach(({ event }, index) => {
144
- if (!AssistantEvent.isLibraryEvent(event)) {
145
- const text = readApp(event)
181
+ let index = -1
146
182
 
147
- if (text !== undefined) input(index, userText(text))
183
+ for (const item of items) {
184
+ if (!isEntry(item)) {
185
+ input(index, (entries[index]?.seq ?? 0) + 0.5, item)
148
186
 
149
- return
187
+ continue
150
188
  }
151
189
 
190
+ index += 1
191
+
192
+ const { event, seq } = item
193
+ const at = index
194
+
195
+ if (!AssistantEvent.isLibraryEvent(event)) continue
196
+
152
197
  Match.value(event).pipe(
153
198
  Match.tagsExhaustive({
154
199
  UserMessage: (message) =>
155
- input(index, userText(AssistantEvent.textWithAttachments(message.text, message.attachments))),
156
- BlockAnswer: (answer) => input(index, userText(answerText(answer))),
200
+ input(at, seq, userText(AssistantEvent.textWithAttachments(message.text, message.attachments))),
201
+ BlockAnswer: (answer) => input(at, seq, userText(answerText(answer))),
157
202
  ModelOutput: (output) => {
158
203
  history.push(...Prompt.fromResponseParts(output.parts).content)
159
204
 
@@ -167,9 +212,11 @@ export const fromEntries = (
167
212
  flushIfClosed()
168
213
  },
169
214
  ToolResult: (result) => {
215
+ if (requests.has(result.callId)) return input(at, seq, userText(approvedText(result)))
216
+
170
217
  // A view that starts after the call (since a summary, say) keeps its result as text: a tool result needs its
171
218
  // call right before it.
172
- if (!called.has(result.callId)) return input(index, userText(resultText(result)))
219
+ if (!called.has(result.callId)) return input(at, seq, userText(resultText(result)))
173
220
 
174
221
  history.push(toolMessage(result))
175
222
  open.delete(result.callId)
@@ -180,21 +227,38 @@ export const fromEntries = (
180
227
  TurnStarted: () => undefined,
181
228
  TurnCompleted: () => undefined,
182
229
  TurnFailed: () => undefined,
183
- SpecialistFinished: (finished) => input(index, userText(finishedText(entries, index, finished))),
230
+ SpecialistFinished: (finished) => input(at, seq, userText(finishedText(entries, at, finished))),
184
231
  // Every run starts in a call of the model's own (a spawn or a follow-up), whose result says so.
185
232
  SpecialistStarted: () => undefined,
186
233
  BlocksClosed: () => undefined,
234
+ // The call's result, for the provider's pairing; what the user decides comes later.
235
+ ApprovalRequested: (request) => {
236
+ requests.set(request.callId, request)
237
+
238
+ if (!called.has(request.callId)) return input(at, seq, userText(awaitingText(request)))
239
+
240
+ history.push(awaitingMessage(request))
241
+ open.delete(request.callId)
242
+ flushIfClosed()
243
+ },
244
+ // An approved call's result says so.
245
+ ApprovalDecision: (decision) =>
246
+ decision.decision === "rejected"
247
+ ? input(at, seq, userText(rejectedText(decision, toolOf(decision.callId))))
248
+ : undefined,
249
+ ApprovalsExpired: (expired) =>
250
+ expired.callIds.forEach((callId) => input(at, seq, userText(expiredText(callId, toolOf(callId))))),
187
251
  ChatDeleted: () => undefined
188
252
  })
189
253
  )
190
- })
254
+ }
191
255
 
192
256
  return [...history, ...held]
193
257
  }
194
258
 
195
259
  /** @internal */
196
- export const isInput = (event: { readonly _tag: string }, wakes: ReadonlySet<string>): boolean =>
197
- AssistantEvent.isInput(event) || (!AssistantEvent.isLibraryEvent(event) && wakes.has(event._tag))
260
+ export const isInput = (event: { readonly _tag: string }): boolean =>
261
+ AssistantEvent.isInput(event) || AssistantEvent.isAppInput(event)
198
262
 
199
263
  /** @internal */
200
264
  export const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): number => {
@@ -206,23 +270,20 @@ export const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>):
206
270
  // A run the assistant stopped isn't an input here: it wakes nothing.
207
271
  /** @internal */
208
272
  export const unhandledInputs = (
209
- entries: ReadonlyArray<AssistantEvent.AnyEntry>,
210
- wakes: ReadonlySet<string>
273
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>
211
274
  ): ReadonlyArray<{ readonly _tag: string }> => {
212
275
  const seen = handledThrough(entries)
213
276
 
214
277
  return entries.flatMap(({ event, seq }) =>
215
- seq > seen && isInput(event, wakes) && !(AssistantEvent.isInput(event) && !AssistantEvent.isWaking(event))
216
- ? [event]
217
- : []
278
+ seq > seen && isInput(event) && !(AssistantEvent.isInput(event) && !AssistantEvent.isWaking(event)) ? [event] : []
218
279
  )
219
280
  }
220
281
 
221
282
  /** @internal */
222
- export const needsTurn = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, wakes: ReadonlySet<string>): boolean =>
223
- unhandledInputs(entries, wakes).length > 0
283
+ export const needsTurn = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): boolean =>
284
+ unhandledInputs(entries).length > 0
224
285
 
225
- // A failure the user was shown counts as a reply.
286
+ // A failure the user was shown counts as a reply, and so does a call they're asked to approve.
226
287
  /** @internal */
227
288
  export const owesReply = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSeq: number): boolean => {
228
289
  const latest = entries.findLast(
@@ -233,7 +294,10 @@ export const owesReply = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throu
233
294
  latest !== undefined &&
234
295
  !entries.some(
235
296
  ({ event, seq }) =>
236
- seq > latest.seq && (Predicate.isTagged(event, "AssistantMessage") || Predicate.isTagged(event, "TurnFailed"))
297
+ seq > latest.seq &&
298
+ (Predicate.isTagged(event, "AssistantMessage") ||
299
+ Predicate.isTagged(event, "TurnFailed") ||
300
+ Predicate.isTagged(event, "ApprovalRequested"))
237
301
  )
238
302
  )
239
303
  }
@@ -278,17 +342,15 @@ export const owesDelivery = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, th
278
342
  )
279
343
  )
280
344
 
345
+ // A call waiting for the user's approval is what comes next, like a specialist's report.
281
346
  return (
282
347
  messages.length > 0 &&
283
348
  messages.every((message) => message.block === undefined && progress.has(message.id)) &&
284
- !after.some((event) => Predicate.isTagged(event, "TurnFailed")) &&
349
+ !after.some((event) => Predicate.isTagged(event, "TurnFailed") || Predicate.isTagged(event, "ApprovalRequested")) &&
285
350
  started.every((runId) => finished.has(runId))
286
351
  )
287
352
  }
288
353
 
289
354
  /** @internal */
290
- export const unreadAfter = (
291
- entries: ReadonlyArray<AssistantEvent.AnyEntry>,
292
- throughSeq: number,
293
- wakes: ReadonlySet<string>
294
- ): boolean => entries.some(({ event, seq }) => seq > throughSeq && isInput(event, wakes))
355
+ export const unreadAfter = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSeq: number): boolean =>
356
+ entries.some(({ event, seq }) => seq > throughSeq && isInput(event))
@@ -0,0 +1,3 @@
1
+ // Marks a stored app event that came in through the assistant's `submit`: an input, which wakes a turn.
2
+ /** @internal */
3
+ export const inputKey = "~effect-assistant/input"
@@ -21,7 +21,18 @@ import * as Toolkits from "./toolkit.ts"
21
21
  import { isTurnEnding } from "./tools.ts"
22
22
 
23
23
  /** @internal */
24
- export type Event = AssistantEvent.ModelOutput | AssistantEvent.ToolStarted | AssistantEvent.ToolResult
24
+ export type Event =
25
+ | AssistantEvent.ModelOutput
26
+ | AssistantEvent.ToolStarted
27
+ | AssistantEvent.ToolResult
28
+ | AssistantEvent.ApprovalRequested
29
+
30
+ // Only a turn's loop can wait for the user's approval, and runs the calls they approved. A turn's specialists inherit
31
+ // its context, so their runs set it back to none.
32
+ /** @internal */
33
+ export const TurnApprovals = Context.Reference<
34
+ Option.Option<{ readonly approved: Effect.Effect<ReadonlyArray<AssistantEvent.ToolCallPart>> }>
35
+ >("effect-assistant/TurnApprovals", { defaultValue: Option.none })
25
36
 
26
37
  // The assistant's own tools with the turn's services, so an app's own loop in a turn can message the user. A
27
38
  // specialist's run has none.
@@ -61,8 +72,9 @@ export interface StepResult {
61
72
  /** The model's output; unset when the step only ran calls a crash cut off. */
62
73
  readonly output: AssistantEvent.ModelOutput | undefined
63
74
  /**
64
- * Whether the model is done: it made no tool calls, or ended its turn (`wait`, or a message
65
- * with `wait: true`) and none of the step's calls failed.
75
+ * Whether the model is done: it made no tool calls, one of its calls waits for the user's
76
+ * approval, or it ended its turn (`wait`, or a message with `wait: true`) and none of the step's
77
+ * calls failed.
66
78
  */
67
79
  readonly done: boolean
68
80
  /**
@@ -71,9 +83,15 @@ export interface StepResult {
71
83
  readonly historyLength: number
72
84
  }
73
85
 
74
- // The loop runs the calls itself, after storing each as started, so the model call runs none.
86
+ // The loop runs the calls itself, after storing each as started, so the model call runs none. It also decides which
87
+ // calls wait for approval: the model call would ask for approvals of its own.
75
88
  const requestToolkit = (definitions: ReadonlyArray<Tool.Any>): Toolkit.WithHandler<Record<string, Tool.Any>> => ({
76
- tools: Object.fromEntries(definitions.map((tool) => [tool.name, tool])),
89
+ tools: Object.fromEntries(
90
+ definitions.map((tool) => [
91
+ tool.name,
92
+ tool.needsApproval === undefined || tool.needsApproval === false ? tool : tool.setNeedsApproval(false)
93
+ ])
94
+ ),
77
95
  handle: () => Effect.succeed(Stream.empty)
78
96
  })
79
97
 
@@ -111,28 +129,79 @@ const runCall = Effect.fnUntraced(function* <R>(tools: Toolkits.Tools<R>, call:
111
129
  return AssistantEvent.ToolResult.make({ callId: call.id, tool: call.name, ...result })
112
130
  })
113
131
 
114
- // Every `ToolStarted` is stored before any call runs. `then` is told whether one of the calls failed, so the model
115
- // has to read the results.
132
+ // A call that needs approval waits for the user in a turn. A specialist's run can't ask them, so its call fails.
133
+ const awaitApproval = Effect.fnUntraced(function* (call: AssistantEvent.ToolCallPart) {
134
+ if (Option.isNone(yield* TurnApprovals)) {
135
+ return AssistantEvent.ToolResult.make({
136
+ callId: call.id,
137
+ tool: call.name,
138
+ ...Toolkits.failed(
139
+ `${call.name} needs the user's approval, and only the assistant can ask for it. Don't run it: say in your answer what needs approving, with the details.`,
140
+ "NeedsApproval"
141
+ )
142
+ })
143
+ }
144
+
145
+ return AssistantEvent.ApprovalRequested.make({ callId: call.id, tool: call.name, params: call.params })
146
+ })
147
+
148
+ interface Pending {
149
+ readonly call: AssistantEvent.ToolCallPart
150
+ /** The user approved it: it runs without asking again. */
151
+ readonly approved: boolean
152
+ }
153
+
154
+ interface Ran {
155
+ /** One of the calls failed, so the model has to read the results. */
156
+ readonly failed: boolean
157
+ /** One of the calls waits for the user's approval, so the turn ends. */
158
+ readonly waiting: boolean
159
+ }
160
+
161
+ // Every `ToolStarted` is stored before any call runs; a call that waits for approval doesn't start.
116
162
  const runCalls = <R, S, E, R2>(
117
163
  tools: Toolkits.Tools<R>,
118
- calls: ReadonlyArray<AssistantEvent.ToolCallPart>,
119
- then: (failed: boolean) => Stream.Stream<Step<never, S>, E, R2>
164
+ pending: ReadonlyArray<Pending>,
165
+ history: ReadonlyArray<Prompt.Message>,
166
+ then: (ran: Ran) => Stream.Stream<Step<never, S>, E, R2>
120
167
  ): Stream.Stream<Step<Event, S>, E, R | R2> =>
121
- Stream.fromIterable(
122
- calls.map((call) => value(AssistantEvent.ToolStarted.make({ callId: call.id, tool: call.name })))
123
- ).pipe(
124
- Stream.concat(
125
- Stream.unwrap(
126
- Effect.gen(function* () {
127
- const done = yield* Effect.forEach(calls, (call) => runCall(tools, call), { concurrency: "unbounded" })
128
-
129
- return Stream.fromIterable(done.map(value)).pipe(Stream.concat(then(done.some(isFailure))))
130
- })
168
+ Stream.unwrap(
169
+ Effect.gen(function* () {
170
+ const waits = yield* Effect.forEach(pending, ({ call, approved }) =>
171
+ approved ? Effect.succeed(false) : tools.needsApproval(call, history)
131
172
  )
132
- )
173
+
174
+ const started = pending.flatMap(({ call }, i) =>
175
+ waits[i] === true ? [] : [value(AssistantEvent.ToolStarted.make({ callId: call.id, tool: call.name }))]
176
+ )
177
+
178
+ return Stream.fromIterable(started).pipe(
179
+ Stream.concat(
180
+ Stream.unwrap(
181
+ Effect.gen(function* () {
182
+ const done = yield* Effect.forEach(
183
+ pending,
184
+ ({ call }, i): Effect.Effect<Event, never, R> =>
185
+ waits[i] === true ? awaitApproval(call) : runCall(tools, call),
186
+ { concurrency: "unbounded" }
187
+ )
188
+
189
+ return Stream.fromIterable(done.map(value)).pipe(
190
+ Stream.concat(
191
+ then({
192
+ failed: done.some(isFailure),
193
+ waiting: done.some((event) => Predicate.isTagged(event, "ApprovalRequested"))
194
+ })
195
+ )
196
+ )
197
+ })
198
+ )
199
+ )
200
+ )
201
+ })
133
202
  )
134
203
 
135
- const isFailure = (result: AssistantEvent.ToolResult) => result.outcome === "failure"
204
+ const isFailure = (event: Event) => Predicate.isTagged(event, "ToolResult") && event.outcome === "failure"
136
205
 
137
206
  /** @internal */
138
207
  export type Step<A, S> = Data.TaggedEnum<{
@@ -192,19 +261,31 @@ export const step = <R, S, E2 = never, R2 = never>(
192
261
  const instructions = Predicate.isString(options.instructions) ? { text: options.instructions } : options.instructions
193
262
 
194
263
  const after = (result: StepResult) => Stream.suspend(() => then(result))
195
- const needingApproval = Toolkits.needingApproval(toolkit.definitions)
196
-
197
- if (needingApproval.length > 0) return Stream.die(Toolkits.needsApprovalMessage(needingApproval))
198
264
 
199
265
  return Stream.unwrap(
200
266
  Effect.gen(function* () {
201
267
  const { messages: history, throughSeq } = readHistory(yield* options.history)
202
268
  // Calls a crash cut off, with no result: they run again, with the same ids, before the model goes on.
203
269
  const cutOff = History.unansweredCalls(history)
270
+ const approvals = yield* TurnApprovals
271
+
272
+ // Calls the user approved: they run as the model made them, with the same ids, before the model goes on. Not in
273
+ // a loop without their tools.
274
+ const approved = Option.isNone(approvals)
275
+ ? []
276
+ : (yield* approvals.value.approved).filter(
277
+ (call) =>
278
+ toolkit.definitions.some((tool) => tool.name === call.name) && !cutOff.some((cut) => cut.id === call.id)
279
+ )
280
+
281
+ if (cutOff.length > 0 || approved.length > 0) {
282
+ const pending = [
283
+ ...cutOff.map((call) => ({ call, approved: false })),
284
+ ...approved.map((call) => ({ call, approved: true }))
285
+ ]
204
286
 
205
- if (cutOff.length > 0) {
206
- return runCalls(toolkit, cutOff, () =>
207
- after({ output: undefined, done: false, historyLength: history.length + cutOff.length })
287
+ return runCalls(toolkit, pending, history, ({ waiting }) =>
288
+ after({ output: undefined, done: waiting, historyLength: history.length + pending.length })
208
289
  )
209
290
  }
210
291
 
@@ -230,15 +311,20 @@ export const step = <R, S, E2 = never, R2 = never>(
230
311
  const calls = toolCalls(output.parts)
231
312
  const historyLength = history.length + Prompt.fromResponseParts(output.parts).content.length + calls.length
232
313
 
233
- // A failed call doesn't end the turn: a message that failed never reached the user, so the model reads why.
314
+ // A failed call doesn't end the turn: a message that failed never reached the user, so the model reads why. A call
315
+ // that waits for approval does: the turn waits for the user.
234
316
  return Stream.make(value(output)).pipe(
235
317
  Stream.concat(
236
- runCalls(toolkit, calls, (failed) =>
237
- after({
238
- output,
239
- done: calls.length === 0 || (!failed && calls.some(isTurnEnding)),
240
- historyLength
241
- })
318
+ runCalls(
319
+ toolkit,
320
+ calls.map((call) => ({ call, approved: false })),
321
+ history,
322
+ ({ failed, waiting }) =>
323
+ after({
324
+ output,
325
+ done: calls.length === 0 || waiting || (!failed && calls.some(isTurnEnding)),
326
+ historyLength
327
+ })
242
328
  )
243
329
  )
244
330
  )
@@ -22,12 +22,9 @@ import * as Loop from "./loop.ts"
22
22
  import { CurrentChat } from "../CurrentChat.ts"
23
23
  import * as InputId from "../InputId.ts"
24
24
  import * as RunId from "../RunId.ts"
25
- import * as Skill from "../Skill.ts"
26
- import * as Skills from "./skills.ts"
27
25
  import * as Specialist from "../Specialist.ts"
28
26
  import * as ToolFailure from "../ToolFailure.ts"
29
27
  import { toolMessage, userText } from "./history.ts"
30
- import { mapText } from "./instructions.ts"
31
28
  import * as Telemetry from "./telemetry.ts"
32
29
  import * as Toolkits from "./toolkit.ts"
33
30
  import type { Writer } from "./writer.ts"
@@ -83,21 +80,19 @@ export const runBrief = Effect.fnUntraced(
83
80
  chatId: options.chatId,
84
81
  brief: options.brief,
85
82
  history: current,
86
- loop: (loop) => {
87
- const skills = loop.skills ?? []
88
-
89
- return Loop.run({
90
- instructions: mapText(loop.instructions, (text) => Skill.withCatalog(text, skills)),
91
- toolkit: Toolkits.merge(Skills.toTools(skills), Toolkits.fromToolkitOrEmpty(loop.toolkit)),
83
+ // A run can't wait for the user's approval: a call that needs it fails, and the run reads why.
84
+ loop: (loop) =>
85
+ Loop.run({
86
+ instructions: loop.instructions,
87
+ toolkit: Toolkits.fromToolkitOrEmpty(loop.toolkit),
92
88
  history: current,
93
89
  maxSteps: loop.maxSteps ?? 8
94
- })
95
- }
90
+ }).pipe(Stream.provideService(Loop.TurnApprovals, Option.none()))
96
91
  }
97
92
 
98
93
  // A run lives in memory, so there's nothing to run again after a crash: `ToolStarted` isn't kept.
99
94
  const record = Effect.fnUntraced(function* (event: AssistantLoop.Event) {
100
- if (Predicate.isTagged(event, "ToolStarted")) return
95
+ if (Predicate.isTagged(event, "ToolStarted") || Predicate.isTagged(event, "ApprovalRequested")) return
101
96
 
102
97
  if (Predicate.isTagged(event, "ToolResult")) return yield* add([toolMessage(event)])
103
98
 
@@ -1,9 +1,11 @@
1
1
  import * as AiError from "effect/ai/AiError"
2
+ import type * as Prompt from "effect/ai/Prompt"
2
3
  import type * as Tool from "effect/ai/Tool"
3
4
  import type * as Toolkit from "effect/ai/Toolkit"
4
5
  import type * as Context from "effect/Context"
5
6
  import * as Effect from "effect/Effect"
6
7
  import * as Option from "effect/Option"
8
+ import * as Predicate from "effect/Predicate"
7
9
  import * as Schema from "effect/Schema"
8
10
  import * as Stream from "effect/Stream"
9
11
  import type * as AssistantEvent from "../AssistantEvent.ts"
@@ -23,13 +25,18 @@ export interface CallResult {
23
25
  export interface Tools<out R = never> {
24
26
  readonly definitions: ReadonlyArray<Tool.Any>
25
27
  readonly run: (call: AssistantEvent.ToolCallPart) => Effect.Effect<CallResult, never, R>
28
+ // Whether the call waits for the user's approval instead of running: its tool's `needsApproval`.
29
+ readonly needsApproval: (
30
+ call: AssistantEvent.ToolCallPart,
31
+ history: ReadonlyArray<Prompt.Message>
32
+ ) => Effect.Effect<boolean, never, R>
26
33
  }
27
34
 
28
35
  /** @internal */
29
- export type ToolServices<T extends Record<string, Tool.Any>> = Exclude<
30
- Tool.HandlerServices<T[keyof T]>,
31
- AssistantToolCall
32
- >
36
+ export type ToolServices<T extends Record<string, Tool.Any>> =
37
+ | Exclude<Tool.HandlerServices<T[keyof T]>, AssistantToolCall>
38
+ // Already in the handlers' services; stated for the loop, which decodes params to ask `needsApproval`.
39
+ | T[keyof T]["parametersSchema"]["DecodingServices"]
33
40
 
34
41
  const encodeFailure = Schema.encodeSync(ToolFailure.ToolFailure)
35
42
 
@@ -60,15 +67,32 @@ const hasTool = <T extends Record<string, Tool.Any>>(
60
67
  name: PropertyKey
61
68
  ): name is keyof T => Object.hasOwn(toolkit.tools, name)
62
69
 
63
- // Approval would park a call until the user decides, which this library doesn't do: the model calls a tool with
64
- // final values, and a user's own commitment goes through the app's API from a block.
65
70
  /** @internal */
66
71
  export const needingApproval = (definitions: ReadonlyArray<Tool.Any>): ReadonlyArray<string> =>
67
72
  definitions.flatMap((tool) => (tool.needsApproval === undefined || tool.needsApproval === false ? [] : [tool.name]))
68
73
 
69
- /** @internal */
70
- export const needsApprovalMessage = (names: ReadonlyArray<string>): string =>
71
- `${names.join(", ")} ${names.length === 1 ? "sets" : "set"} needsApproval, and effect-assistant doesn't run tools that wait for approval. Remove needsApproval: let the model call the tool with final values, or show a block whose button calls your app's API, which does the work and submits an app event that wakes the chat.`
74
+ // With noUncheckedIndexedAccess, an index doesn't narrow on `hasTool`.
75
+ const toolNamed = (toolkit: { readonly tools: Record<string, Tool.Any> }, name: string): Tool.Any | undefined =>
76
+ Object.hasOwn(toolkit.tools, name) ? toolkit.tools[name] : undefined
77
+
78
+ // Params that don't fit the tool don't wait: the call runs, and fails with why.
79
+ const needsApprovalNative = Effect.fnUntraced(function* <T extends Record<string, Tool.Any>>(
80
+ toolkit: { readonly tools: T },
81
+ call: AssistantEvent.ToolCallPart,
82
+ history: ReadonlyArray<Prompt.Message>
83
+ ): Effect.fn.Return<boolean, never, ToolServices<T>> {
84
+ const tool = toolNamed(toolkit, call.name)
85
+ const needs = tool?.needsApproval
86
+
87
+ if (tool === undefined || needs === undefined || Predicate.isBoolean(needs)) return needs === true
88
+
89
+ return yield* Effect.gen(function* () {
90
+ const params = yield* Schema.decodeUnknownEffect(tool.parametersSchema)(call.params)
91
+ const decided = needs(params, { toolCallId: call.id, messages: history })
92
+
93
+ return Effect.isEffect(decided) ? yield* decided : decided
94
+ }).pipe(Effect.orElseSucceed(() => false))
95
+ })
72
96
 
73
97
  // The toolkit decodes the params with the tool's schema before its handler sees them, and a call whose params don't
74
98
  // fit fails with a validation error the model reads: the model's JSON is what `handle` takes.
@@ -117,7 +141,8 @@ export const fromWithHandler = <T extends Record<string, Tool.Any>>(
117
141
  toolkit: Toolkit.WithHandler<T>
118
142
  ): Tools<ToolServices<T>> => ({
119
143
  definitions: Object.values(toolkit.tools),
120
- run: (call) => runNative(toolkit, call)
144
+ run: (call) => runNative(toolkit, call),
145
+ needsApproval: (call, history) => needsApprovalNative(toolkit, call, history)
121
146
  })
122
147
 
123
148
  /** @internal */
@@ -127,22 +152,8 @@ export const fromToolkit = <T extends Record<string, Tool.Any>>(
127
152
  definitions: Object.values(toolkit.tools),
128
153
  run: Effect.fnUntraced(function* (call) {
129
154
  return yield* runNative(yield* toolkit, call)
130
- })
131
- })
132
-
133
- // The handlers use services of the loop they run in, so they're made for each call, with its context.
134
- /** @internal */
135
- export const withContext = <T extends Record<string, Tool.Any>, R>(
136
- toolkit: Toolkit.Toolkit<T>,
137
- handlers: (context: Context.Context<R>) => Toolkit.HandlersFrom<T>
138
- ): Tools<R | ToolServices<T>> => ({
139
- definitions: Object.values(toolkit.tools),
140
- run: Effect.fnUntraced(function* (call) {
141
- const context = yield* Effect.context<R>()
142
- const resolved = yield* Effect.provideContext(toolkit, yield* toolkit.toHandlers(handlers(context)))
143
-
144
- return yield* runNative(resolved, call)
145
- })
155
+ }),
156
+ needsApproval: (call, history) => needsApprovalNative(toolkit, call, history)
146
157
  })
147
158
 
148
159
  /** @internal */
@@ -153,7 +164,8 @@ export const fromToolkitOrEmpty = <T extends Record<string, Tool.Any>>(
153
164
  /** @internal */
154
165
  export const empty: Tools = {
155
166
  definitions: [],
156
- run: (call) => Effect.succeed(failed(`No tool named ${call.name}.`, "ToolNotFoundError"))
167
+ run: (call) => Effect.succeed(failed(`No tool named ${call.name}.`, "ToolNotFoundError")),
168
+ needsApproval: () => Effect.succeed(false)
157
169
  }
158
170
 
159
171
  /** @internal */
@@ -162,7 +174,9 @@ export const merge = <A, B>(self: Tools<A>, that: Tools<B>): Tools<A | B> => {
162
174
 
163
175
  return {
164
176
  definitions: [...self.definitions, ...that.definitions],
165
- run: (call) => (inSelf(call) ? self.run(call) : that.run(call))
177
+ run: (call) => (inSelf(call) ? self.run(call) : that.run(call)),
178
+ needsApproval: (call, history) =>
179
+ inSelf(call) ? self.needsApproval(call, history) : that.needsApproval(call, history)
166
180
  }
167
181
  }
168
182
 
@@ -170,7 +184,8 @@ export const merge = <A, B>(self: Tools<A>, that: Tools<B>): Tools<A | B> => {
170
184
  /** @internal */
171
185
  export const provide = <R>(tools: Tools<R>, context: Context.Context<R>): Tools => ({
172
186
  definitions: tools.definitions,
173
- run: (call) => Effect.provideContext(tools.run(call), context)
187
+ run: (call) => Effect.provideContext(tools.run(call), context),
188
+ needsApproval: (call, history) => Effect.provideContext(tools.needsApproval(call, history), context)
174
189
  })
175
190
 
176
191
  /** @internal */
@@ -184,5 +199,7 @@ export const duplicateNames = (tools: Tools<unknown>): ReadonlyArray<string> =>
184
199
  /** @internal */
185
200
  export const runOnly = <R>(tools: Tools<R>, names: ReadonlyArray<string>, message: string): Tools<R> => ({
186
201
  definitions: tools.definitions,
187
- run: (call) => (names.includes(call.name) ? tools.run(call) : Effect.succeed(failed(message, "NotNow")))
202
+ run: (call) => (names.includes(call.name) ? tools.run(call) : Effect.succeed(failed(message, "NotNow"))),
203
+ // Refused like any other call it doesn't run.
204
+ needsApproval: () => Effect.succeed(false)
188
205
  })