effect-assistant 0.3.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 (73) hide show
  1. package/dist/Assistant.d.ts +5 -11
  2. package/dist/Assistant.d.ts.map +1 -1
  3. package/dist/Assistant.js.map +1 -1
  4. package/dist/AssistantEvent.d.ts +55 -7
  5. package/dist/AssistantEvent.d.ts.map +1 -1
  6. package/dist/AssistantEvent.js +61 -7
  7. package/dist/AssistantEvent.js.map +1 -1
  8. package/dist/AssistantLoop.d.ts +4 -2
  9. package/dist/AssistantLoop.d.ts.map +1 -1
  10. package/dist/AssistantLoop.js +4 -2
  11. package/dist/AssistantLoop.js.map +1 -1
  12. package/dist/Compaction.d.ts +1 -1
  13. package/dist/Compaction.d.ts.map +1 -1
  14. package/dist/Compaction.js +13 -6
  15. package/dist/Compaction.js.map +1 -1
  16. package/dist/Skill.d.ts +85 -43
  17. package/dist/Skill.d.ts.map +1 -1
  18. package/dist/Skill.js +93 -44
  19. package/dist/Skill.js.map +1 -1
  20. package/dist/Specialist.d.ts +6 -9
  21. package/dist/Specialist.d.ts.map +1 -1
  22. package/dist/Specialist.js.map +1 -1
  23. package/dist/Timeline.d.ts +40 -6
  24. package/dist/Timeline.d.ts.map +1 -1
  25. package/dist/Timeline.js +74 -15
  26. package/dist/Timeline.js.map +1 -1
  27. package/dist/ToolApproval.d.ts +146 -0
  28. package/dist/ToolApproval.d.ts.map +1 -0
  29. package/dist/ToolApproval.js +201 -0
  30. package/dist/ToolApproval.js.map +1 -0
  31. package/dist/ToolDiscovery.d.ts +6 -4
  32. package/dist/ToolDiscovery.d.ts.map +1 -1
  33. package/dist/ToolDiscovery.js +5 -4
  34. package/dist/ToolDiscovery.js.map +1 -1
  35. package/dist/index.d.ts +3 -2
  36. package/dist/index.js +3 -2
  37. package/dist/internal/chat.js +4 -1
  38. package/dist/internal/chat.js.map +1 -1
  39. package/dist/internal/history.js +28 -3
  40. package/dist/internal/history.js.map +1 -1
  41. package/dist/internal/loop.d.ts +10 -5
  42. package/dist/internal/loop.d.ts.map +1 -1
  43. package/dist/internal/loop.js +54 -21
  44. package/dist/internal/loop.js.map +1 -1
  45. package/dist/internal/specialists.js +9 -16
  46. package/dist/internal/specialists.js.map +1 -1
  47. package/dist/internal/toolkit.d.ts +5 -7
  48. package/dist/internal/toolkit.d.ts.map +1 -1
  49. package/dist/internal/toolkit.js +28 -18
  50. package/dist/internal/toolkit.js.map +1 -1
  51. package/dist/internal/turns.d.ts.map +1 -1
  52. package/dist/internal/turns.js +18 -13
  53. package/dist/internal/turns.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/Assistant.ts +7 -13
  56. package/src/AssistantEvent.ts +60 -6
  57. package/src/AssistantLoop.ts +4 -2
  58. package/src/Compaction.ts +19 -2
  59. package/src/Skill.ts +134 -57
  60. package/src/Specialist.ts +8 -11
  61. package/src/Timeline.ts +104 -18
  62. package/src/ToolApproval.ts +249 -0
  63. package/src/ToolDiscovery.ts +8 -3
  64. package/src/index.ts +5 -0
  65. package/src/internal/chat.ts +11 -1
  66. package/src/internal/history.ts +58 -3
  67. package/src/internal/loop.ts +120 -34
  68. package/src/internal/specialists.ts +7 -13
  69. package/src/internal/toolkit.ts +47 -30
  70. package/src/internal/turns.ts +24 -7
  71. package/dist/internal/skills.js +0 -33
  72. package/dist/internal/skills.js.map +0 -1
  73. package/src/internal/skills.ts +0 -48
package/src/Timeline.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * This module provides a chat's timeline: the chat as its user sees it. A chat client receives the
3
3
  * timeline and nothing else.
4
4
  *
5
- * A {@link Timeline} holds the chat's items: the messages with their blocks, and the app's own
6
- * events it shows (each typed with its event, see {@link EventItem}). It also holds the chat's
5
+ * A {@link Timeline} holds the chat's items: the messages with their blocks, the app's own events
6
+ * it shows (each typed with its event, see {@link EventItem}), and the tool calls waiting for the
7
+ * user's approval ({@link ApprovalItem}). It also holds the chat's
7
8
  * {@link Activity}, and whether the last turn failed or the chat was deleted. It never contains the
8
9
  * model's output, tool calls or results, what the model read, or why a turn failed. The server
9
10
  * streams {@link Change}s by piping the assistant's `subscribe` through {@link changes}, and the
@@ -20,6 +21,7 @@
20
21
  import { dual } from "effect/Function"
21
22
  import * as Match from "effect/Match"
22
23
  import * as Option from "effect/Option"
24
+ import * as Predicate from "effect/Predicate"
23
25
  import * as Schema from "effect/Schema"
24
26
  import * as SchemaTransformation from "effect/SchemaTransformation"
25
27
  import * as Stream from "effect/Stream"
@@ -29,6 +31,7 @@ import * as AssistantEvent from "./AssistantEvent.ts"
29
31
  import * as BlockKit from "./BlockKit.ts"
30
32
  import { InputId } from "./InputId.ts"
31
33
  import * as AppEvents from "./internal/events.ts"
34
+ import * as ToolApproval from "./ToolApproval.ts"
32
35
  import { toJson } from "./internal/schema.ts"
33
36
 
34
37
  /**
@@ -135,8 +138,43 @@ export type EventItem<E> = E extends { readonly _tag: infer Tag extends string }
135
138
  : never
136
139
 
137
140
  /**
138
- * Represents an item in the chat's timeline: a message the user or the assistant sent, or one of
139
- * the app's own events it shows (see {@link EventItem}).
141
+ * Schema for a tool call that waits, or waited, for the user's approval, as an item on the
142
+ * timeline.
143
+ *
144
+ * **Details**
145
+ *
146
+ * It is only the yes or no: the assistant's own message tells the user what it asks them to
147
+ * approve, so the item carries no params. `id` is the call's id, which the decision is for (see
148
+ * `ToolApproval.approve`), and `tool` the tool's name, for a label. `status` is where the call stands
149
+ * (`ToolApproval.Status`); `reason` is the user's, when they rejected it with one; `outcome` is how
150
+ * the approved call went, once it ran. `at` is when the call started waiting, in epoch
151
+ * milliseconds. A change updates the item in place as the call is decided and run.
152
+ *
153
+ * @category schemas
154
+ * @since 0.4.0
155
+ */
156
+ export const ApprovalItem = Schema.TaggedStruct("ToolApproval", {
157
+ id: Schema.String,
158
+ tool: Schema.String,
159
+ status: Schema.Literals(["pending", "approved", "rejected", "expired"]),
160
+ reason: Schema.optional(Schema.String),
161
+ outcome: Schema.optional(AssistantEvent.ToolOutcome),
162
+ at: Schema.Finite
163
+ })
164
+
165
+ /**
166
+ * Represents a tool call that waits, or waited, for the user's approval, as an item on the
167
+ * timeline.
168
+ *
169
+ * @category models
170
+ * @since 0.4.0
171
+ */
172
+ export type ApprovalItem = typeof ApprovalItem.Type
173
+
174
+ /**
175
+ * Represents an item in the chat's timeline: a message the user or the assistant sent, one of the
176
+ * app's own events it shows (see {@link EventItem}), or a tool call waiting for the user's approval
177
+ * (see {@link ApprovalItem}).
140
178
  *
141
179
  * **Example** (Showing each item of a chat)
142
180
  *
@@ -158,7 +196,7 @@ export type EventItem<E> = E extends { readonly _tag: infer Tag extends string }
158
196
  * @category models
159
197
  * @since 0.3.0
160
198
  */
161
- export type Item<Events = never> = UserMessage | AssistantMessage | EventItem<Events>
199
+ export type Item<Events = never> = UserMessage | AssistantMessage | EventItem<Events> | ApprovalItem
162
200
 
163
201
  /**
164
202
  * Schema for a specialist that is working for the assistant. It holds the specialist's name and
@@ -227,7 +265,7 @@ export interface Timeline<Events = never> {
227
265
  * **Details**
228
266
  *
229
267
  * A change holds each item that is new or changed (for example, a message whose block was
230
- * answered or closed), in full. It also holds the current activity, whether the last turn failed,
268
+ * answered or closed, or a call the user approved), in full. It also holds the current activity, whether the last turn failed,
231
269
  * and whether the chat was deleted. `seq` is the position in the chat's log that the change is up
232
270
  * to.
233
271
  *
@@ -250,7 +288,10 @@ function makeChange<const Events extends ReadonlyArray<AssistantEvent.AppEventSc
250
288
  ): Schema.Codec<Change<Events[number]["Type"]>, Schema.Json>
251
289
  // The overload states the decoded type: an item per given schema, each typed with its own event.
252
290
  function makeChange(events: ReadonlyArray<AssistantEvent.AppEventSchema> = []): Schema.Top {
253
- const AnyItem = Schema.toCodecJson(Schema.Union([UserMessage, AssistantMessage, ...events.map(eventItem)]))
291
+ const AnyItem = Schema.toCodecJson(
292
+ Schema.Union([UserMessage, AssistantMessage, ApprovalItem, ...events.map(eventItem)])
293
+ )
294
+
254
295
  const decodeItem = Schema.decodeUnknownOption(AnyItem)
255
296
  const encodeItem = Schema.encodeUnknownSync(AnyItem)
256
297
 
@@ -280,7 +321,8 @@ function makeChange(events: ReadonlyArray<AssistantEvent.AppEventSchema> = []):
280
321
  * **Details**
281
322
  *
282
323
  * It is the success schema of the RPC that streams a chat's timeline: name the events the chat's
283
- * users see, the same ones the handler passes to {@link changes} as `show`. Each item decodes its
324
+ * users see, the same ones the handler passes to {@link changes} as `show`. Tool calls waiting for
325
+ * approval are always in it, as {@link ApprovalItem}s. Each item decodes its
284
326
  * event with that event's schema, so the client gets `Change<Events>` typed with them and folds it
285
327
  * with {@link fold}, no definition needed. `Change()` carries no app events.
286
328
  *
@@ -417,10 +459,12 @@ interface AnyEventItem {
417
459
  readonly event: { readonly _tag: string }
418
460
  }
419
461
 
462
+ type AnyItem = UserMessage | AssistantMessage | AnyEventItem | ApprovalItem
463
+
420
464
  // What the fold below builds: the overloads that use it type its items with the events shown.
421
465
  interface AnyChange {
422
466
  readonly seq: number
423
- readonly items: ReadonlyArray<UserMessage | AssistantMessage | AnyEventItem>
467
+ readonly items: ReadonlyArray<AnyItem>
424
468
  readonly activity: Activity
425
469
  readonly failed: boolean
426
470
  readonly deleted: boolean
@@ -575,13 +619,14 @@ const sameActivity = (a: Activity, b: Activity) =>
575
619
 
576
620
  const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding => {
577
621
  const shown = new Map(show.map((schema) => [AppEvents.tagOf(schema), schema]))
622
+ let approvals: ReadonlyMap<string, ToolApproval.State> = new Map()
578
623
  let seq = 0
579
624
  let failed = false
580
625
  let deleted = false
581
626
  let activity = AssistantActivity.empty
582
627
  let blocks: ReadonlyMap<string, BlockKit.State> = new Map()
583
628
  // The items in order, and the assistant's messages as stored: their blocks change as the chat goes on.
584
- const items = new Map<string, UserMessage | AssistantMessage | AnyEventItem>()
629
+ const items = new Map<string, AnyItem>()
585
630
  const sent = new Map<string, AssistantEvent.AssistantMessage>()
586
631
 
587
632
  const blockOf = (message: AssistantEvent.AssistantMessage): Block | undefined => {
@@ -634,6 +679,13 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
634
679
  return [answer.messageId]
635
680
  },
636
681
  BlocksClosed: (closed) => closed.messageIds,
682
+ ApprovalRequested: () => [],
683
+ ApprovalDecision: () => {
684
+ failed = false
685
+
686
+ return []
687
+ },
688
+ ApprovalsExpired: () => [],
637
689
  TurnFailed: () => {
638
690
  failed = true
639
691
 
@@ -670,8 +722,38 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
670
722
  }))
671
723
  }
672
724
 
725
+ const callsOf = (event: AssistantEvent.AssistantEvent): ReadonlyArray<string> =>
726
+ Predicate.isTagged(event, "ApprovalsExpired")
727
+ ? event.callIds
728
+ : Predicate.isTagged(event, "ApprovalRequested") ||
729
+ Predicate.isTagged(event, "ApprovalDecision") ||
730
+ Predicate.isTagged(event, "ToolResult")
731
+ ? [event.callId]
732
+ : []
733
+
734
+ // The items of the calls this entry changed.
735
+ const approvalItems = (event: AssistantEvent.AssistantEvent, before: ReadonlyMap<string, ToolApproval.State>) =>
736
+ callsOf(event).flatMap((callId) => {
737
+ const state = approvals.get(callId)
738
+
739
+ if (state === undefined || state === before.get(callId)) return []
740
+
741
+ const item = ApprovalItem.make({
742
+ id: callId,
743
+ tool: state.request.tool,
744
+ status: state.status,
745
+ reason: state.reason,
746
+ outcome: state.outcome,
747
+ at: state.at
748
+ })
749
+
750
+ items.set(callId, item)
751
+
752
+ return [item]
753
+ })
754
+
673
755
  const step = (entry: AssistantEvent.AnyEntry): AnyChange | undefined => {
674
- const before = { activity: activityOf(activity), failed, deleted }
756
+ const previous = { activity: activityOf(activity), failed, deleted }
675
757
  const { event } = entry
676
758
 
677
759
  seq = entry.seq
@@ -682,7 +764,7 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
682
764
 
683
765
  return Option.match(eventItemOf(entry), {
684
766
  onNone: () =>
685
- sameActivity(before.activity, now) ? undefined : { seq, items: [], activity: now, failed, deleted },
767
+ sameActivity(previous.activity, now) ? undefined : { seq, items: [], activity: now, failed, deleted },
686
768
  onSome: (item) => {
687
769
  items.set(item.id, item)
688
770
 
@@ -693,6 +775,9 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
693
775
 
694
776
  blocks = BlockKit.apply(blocks, event)
695
777
 
778
+ const decidedBefore = approvals
779
+ approvals = ToolApproval.apply(approvals, entry)
780
+
696
781
  const changed = [...new Set(touched(event, entry.at))].flatMap((id) => {
697
782
  const message = sent.get(id)
698
783
  const current = items.get(id)
@@ -705,14 +790,15 @@ const folding = (show: ReadonlyArray<AssistantEvent.AppEventSchema>): Folding =>
705
790
  return [next]
706
791
  })
707
792
 
793
+ const all = [...changed, ...approvalItems(event, decidedBefore)]
708
794
  const now = activityOf(activity)
709
795
 
710
- return changed.length === 0 &&
711
- failed === before.failed &&
712
- deleted === before.deleted &&
713
- sameActivity(before.activity, now)
796
+ return all.length === 0 &&
797
+ failed === previous.failed &&
798
+ deleted === previous.deleted &&
799
+ sameActivity(previous.activity, now)
714
800
  ? undefined
715
- : { seq, items: changed, activity: now, failed, deleted }
801
+ : { seq, items: all, activity: now, failed, deleted }
716
802
  }
717
803
 
718
804
  return {
@@ -728,7 +814,7 @@ const merge = (changes: ReadonlyArray<AnyChange>): AnyChange | undefined => {
728
814
 
729
815
  if (last === undefined) return undefined
730
816
 
731
- const items = new Map<string, UserMessage | AssistantMessage | AnyEventItem>()
817
+ const items = new Map<string, AnyItem>()
732
818
 
733
819
  for (const change of changes) for (const item of change.items) items.set(item.id, item)
734
820
 
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Tool calls that wait for the user's approval.
3
+ *
4
+ * A tool made with Effect's `needsApproval` doesn't run when the model calls it in a turn: the
5
+ * call waits ({@link AssistantEvent.ApprovalRequested}), the turn ends, and the chat's timeline
6
+ * shows it to the user. Their decision, built with {@link approve} or {@link reject} and submitted
7
+ * like a message, wakes a turn: an approved call runs then, with its params and id, and a rejected
8
+ * one doesn't. A call the user moved on from expires ({@link closeStale}, like `BlockKit.closeStale`). On the timeline, every
9
+ * call is an item with its tool's name and where it stands: only the yes or no, since the
10
+ * assistant's message says what it asks.
11
+ *
12
+ * @since 0.4.0
13
+ */
14
+ import * as Effect from "effect/Effect"
15
+ import * as Predicate from "effect/Predicate"
16
+ import * as AssistantEvent from "./AssistantEvent.ts"
17
+ import { CurrentChat } from "./CurrentChat.ts"
18
+ import * as InputId from "./InputId.ts"
19
+
20
+ /**
21
+ * Represents where a call that waits for approval stands: `"pending"` until the user decides,
22
+ * then `"approved"` or `"rejected"`, or `"expired"` when the user moved on.
23
+ *
24
+ * @category models
25
+ * @since 0.4.0
26
+ */
27
+ export type Status = "pending" | "approved" | "rejected" | "expired"
28
+
29
+ /**
30
+ * Represents a tool call that waited, or waits, for the user's approval.
31
+ *
32
+ * @category models
33
+ * @since 0.4.0
34
+ */
35
+ export interface State {
36
+ readonly request: AssistantEvent.ApprovalRequested
37
+ /** When the call started waiting, in epoch milliseconds. */
38
+ readonly at: number
39
+ readonly status: Status
40
+ /** The user's reason, when they rejected it with one. */
41
+ readonly reason: string | undefined
42
+ /** How the approved call went, once it ran. */
43
+ readonly outcome: AssistantEvent.ToolOutcome | undefined
44
+ }
45
+
46
+ const decided = (state: State, decision: AssistantEvent.ApprovalDecision): State =>
47
+ decision.reason === undefined
48
+ ? { ...state, status: decision.decision }
49
+ : { ...state, status: decision.decision, reason: decision.reason }
50
+
51
+ /**
52
+ * Returns the approvals after one more entry. Fold a chat's entries in order, or use
53
+ * {@link fromEntries}.
54
+ *
55
+ * **Details**
56
+ *
57
+ * The first of a call's decision and its expiry counts: anything after it changes nothing.
58
+ *
59
+ * @category combinators
60
+ * @since 0.4.0
61
+ */
62
+ export const apply = (
63
+ states: ReadonlyMap<string, State>,
64
+ entry: AssistantEvent.AnyEntry
65
+ ): ReadonlyMap<string, State> => {
66
+ const { event } = entry
67
+
68
+ if (!AssistantEvent.isLibraryEvent(event)) return states
69
+
70
+ if (Predicate.isTagged(event, "ApprovalRequested")) {
71
+ return states.has(event.callId)
72
+ ? states
73
+ : new Map(states).set(event.callId, {
74
+ request: event,
75
+ at: entry.at,
76
+ status: "pending",
77
+ reason: undefined,
78
+ outcome: undefined
79
+ })
80
+ }
81
+
82
+ if (Predicate.isTagged(event, "ApprovalDecision")) {
83
+ const state = states.get(event.callId)
84
+
85
+ return state === undefined || state.status !== "pending"
86
+ ? states
87
+ : new Map(states).set(event.callId, decided(state, event))
88
+ }
89
+
90
+ if (Predicate.isTagged(event, "ApprovalsExpired")) {
91
+ const expired = event.callIds.flatMap((callId) => {
92
+ const state = states.get(callId)
93
+
94
+ return state === undefined || state.status !== "pending" ? [] : [{ ...state, status: "expired" as const }]
95
+ })
96
+
97
+ if (expired.length === 0) return states
98
+
99
+ const next = new Map(states)
100
+
101
+ for (const state of expired) next.set(state.request.callId, state)
102
+
103
+ return next
104
+ }
105
+
106
+ if (Predicate.isTagged(event, "ToolResult")) {
107
+ const state = states.get(event.callId)
108
+
109
+ return state === undefined || state.status !== "approved"
110
+ ? states
111
+ : new Map(states).set(event.callId, { ...state, outcome: event.outcome })
112
+ }
113
+
114
+ return states
115
+ }
116
+
117
+ /**
118
+ * Returns every call that waited for approval in a chat, by call id, with where it stands.
119
+ *
120
+ * @category constructors
121
+ * @since 0.4.0
122
+ */
123
+ export const fromEntries = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): ReadonlyMap<string, State> =>
124
+ entries.reduce(apply, new Map<string, State>())
125
+
126
+ /**
127
+ * The id of a decision on a call: the same for every decision on it, so the chat stores one.
128
+ *
129
+ * @category utils
130
+ * @since 0.4.0
131
+ */
132
+ export const decisionId = (callId: string): InputId.InputId => InputId.fromKey(`approval:${callId}`)
133
+
134
+ /**
135
+ * Returns the input that approves a call waiting for the user's approval, to submit like a
136
+ * message. `approval` is the call's item on the chat's timeline, or anything with the call's id.
137
+ *
138
+ * **Example** (Approve and reject buttons on the client)
139
+ *
140
+ * ```tsx
141
+ * import { ToolApproval } from "effect-assistant"
142
+ *
143
+ * const buttons = (approval: { readonly id: string; readonly status: ToolApproval.Status }) =>
144
+ * approval.status === "pending" ? (
145
+ * <>
146
+ * <button onClick={() => submit(ToolApproval.approve(approval))}>Approve</button>
147
+ * <button onClick={() => submit(ToolApproval.reject(approval, { reason: "Not this week" }))}>Reject</button>
148
+ * </>
149
+ * ) : null
150
+ * ```
151
+ *
152
+ * @category constructors
153
+ * @since 0.4.0
154
+ */
155
+ export const approve = (approval: { readonly id: string }): AssistantEvent.ApprovalDecision =>
156
+ AssistantEvent.ApprovalDecision.make({ id: decisionId(approval.id), callId: approval.id, decision: "approved" })
157
+
158
+ /**
159
+ * Returns the input that rejects a call waiting for the user's approval, with an optional short
160
+ * reason the model reads.
161
+ *
162
+ * @see {@link approve} for an example
163
+ *
164
+ * @category constructors
165
+ * @since 0.4.0
166
+ */
167
+ export const reject = (
168
+ approval: { readonly id: string },
169
+ options?: { readonly reason?: string | undefined }
170
+ ): AssistantEvent.ApprovalDecision =>
171
+ options?.reason === undefined
172
+ ? AssistantEvent.ApprovalDecision.make({ id: decisionId(approval.id), callId: approval.id, decision: "rejected" })
173
+ : AssistantEvent.ApprovalDecision.make({
174
+ id: decisionId(approval.id),
175
+ callId: approval.id,
176
+ decision: "rejected",
177
+ reason: options.reason
178
+ })
179
+
180
+ /**
181
+ * Returns the ids of the calls still waiting for approval after the user typed the given number
182
+ * of messages without deciding them.
183
+ *
184
+ * **Details**
185
+ *
186
+ * These are the calls {@link closeStale} closes.
187
+ *
188
+ * @category utils
189
+ * @since 0.4.0
190
+ */
191
+ export const staleApprovals = (
192
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>,
193
+ options: { readonly messages: number }
194
+ ): ReadonlyArray<string> => {
195
+ const states = fromEntries(entries)
196
+
197
+ return [...states.values()].flatMap((state) => {
198
+ if (state.status !== "pending") return []
199
+
200
+ const requested = entries.findIndex(
201
+ ({ event }) =>
202
+ AssistantEvent.isLibraryEvent(event) &&
203
+ Predicate.isTagged(event, "ApprovalRequested") &&
204
+ event.callId === state.request.callId
205
+ )
206
+
207
+ const typed = entries.slice(requested + 1).filter(({ event }) => Predicate.isTagged(event, "UserMessage")).length
208
+
209
+ return typed >= options.messages ? [state.request.callId] : []
210
+ })
211
+ }
212
+
213
+ /**
214
+ * Closes the calls the user moved on from, which still wait for approval after the given number
215
+ * of typed messages: they expire, and won't run.
216
+ *
217
+ * **Details**
218
+ *
219
+ * Run it from a turn, before or after its loop, like `BlockKit.closeStale`. It records an
220
+ * `ApprovalsExpired` event: the timeline shows the calls as expired, the model reads that they
221
+ * weren't approved, and a decision that arrives later isn't stored.
222
+ *
223
+ * **Example** (Closing calls the user moved on from, and stale blocks, every turn)
224
+ *
225
+ * ```ts
226
+ * import { BlockKit, ToolApproval } from "effect-assistant"
227
+ * import * as Effect from "effect/Effect"
228
+ *
229
+ * const MyAssistantLive = MyAssistant.toLayer(
230
+ * Effect.fn(function* (turn) {
231
+ * yield* BlockKit.closeStale({ messages: 10 })
232
+ * yield* ToolApproval.closeStale({ messages: 10 })
233
+ *
234
+ * return turn.loop({ instructions, toolkit: MyTools })
235
+ * })
236
+ * )
237
+ * ```
238
+ *
239
+ * @see {@link staleApprovals} for the calls it closes
240
+ *
241
+ * @category utils
242
+ * @since 0.4.0
243
+ */
244
+ export const closeStale = Effect.fn(function* (options: { readonly messages: number }) {
245
+ const chat = yield* CurrentChat
246
+ const stale = staleApprovals(yield* chat.entries, options)
247
+
248
+ if (stale.length > 0) yield* chat.append(AssistantEvent.ApprovalsExpired.make({ callIds: stale }))
249
+ })
@@ -94,8 +94,9 @@ const requiring =
94
94
  * the tools' handlers. A catalog tool's services are `execute_tool`'s, so the turn provides them
95
95
  * as it does for any tool.
96
96
  *
97
- * A catalog tool with Effect's `needsApproval` set fails the layer when it is built, as it would
98
- * fail a loop: the library doesn't run calls that wait for approval.
97
+ * A catalog tool with Effect's `needsApproval` set fails the layer when it is built: a call
98
+ * through `execute_tool` can't wait for the user's approval. Give such a tool to the loop's toolkit
99
+ * directly (see `ToolApproval`).
99
100
  *
100
101
  * **Example** (Finding rarely used tools when needed)
101
102
  *
@@ -148,7 +149,11 @@ export const layer = <T extends Record<string, Tool.Any>>(catalog: Toolkit.Toolk
148
149
  const definitions = Object.values(resolved.tools)
149
150
  const needingApproval = Toolkits.needingApproval(definitions)
150
151
 
151
- if (needingApproval.length > 0) return yield* Effect.die(Toolkits.needsApprovalMessage(needingApproval))
152
+ if (needingApproval.length > 0) {
153
+ return yield* Effect.die(
154
+ `${needingApproval.join(", ")} ${needingApproval.length === 1 ? "sets" : "set"} needsApproval, and a call through execute_tool can't wait for the user's approval. Give it to the loop's toolkit instead of ToolDiscovery's catalog.`
155
+ )
156
+ }
152
157
 
153
158
  return {
154
159
  search_tools: ({ query }) =>
package/src/index.ts CHANGED
@@ -104,6 +104,11 @@ export * as Specialist from "./Specialist.ts"
104
104
  */
105
105
  export * as Timeline from "./Timeline.ts"
106
106
 
107
+ /**
108
+ * @since 0.4.0
109
+ */
110
+ export * as ToolApproval from "./ToolApproval.ts"
111
+
107
112
  /**
108
113
  * @since 0.1.0
109
114
  */
@@ -18,6 +18,7 @@ import { ChatLog } from "../ChatLog.ts"
18
18
  import { CurrentChat } from "../CurrentChat.ts"
19
19
  import * as Chats from "../Chats.ts"
20
20
  import * as SpecialistService from "../Specialist.ts"
21
+ import * as ToolApproval from "../ToolApproval.ts"
21
22
  import * as AppEvents from "./events.ts"
22
23
  import * as History from "./history.ts"
23
24
  import * as Specialists from "./specialists.ts"
@@ -197,8 +198,17 @@ export const make = Effect.fnUntraced(function* (options: {
197
198
  if (yield* isDeleted) return yield* refused(input, "is deleted")
198
199
 
199
200
  const id = idOf(input)
201
+ const all = yield* Ref.get(entries)
200
202
 
201
- if (id !== undefined && (yield* Ref.get(entries)).some(({ event }) => idOf(event) === id)) return
203
+ if (id !== undefined && all.some(({ event }) => idOf(event) === id)) return
204
+
205
+ // A decision counts only for a call that still waits: not one decided, or expired, or unknown.
206
+ if (
207
+ Predicate.isTagged(input, "ApprovalDecision") &&
208
+ ToolApproval.fromEntries(all).get(input.callId)?.status !== "pending"
209
+ ) {
210
+ return yield* refused(input, "has no call waiting for that decision")
211
+ }
202
212
 
203
213
  // Stored as an input: it came in from outside a turn, so it wakes one, also after a restart.
204
214
  yield* write([AssistantEvent.isLibraryEvent(input) ? input : AppEvents.markInput(input)])
@@ -110,6 +110,35 @@ 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
+ /** @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
+
113
142
  const isEntry = (item: AssistantEvent.AnyEntry | Prompt.Message): item is AssistantEvent.AnyEntry =>
114
143
  Predicate.hasProperty(item, "seq")
115
144
 
@@ -125,6 +154,9 @@ export const fromEntries = (
125
154
  const held: Array<Prompt.Message> = []
126
155
  const open = new Set<string>()
127
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"
128
160
 
129
161
  const flushIfClosed = () => {
130
162
  if (open.size === 0) history.push(...held.splice(0))
@@ -180,6 +212,8 @@ export const fromEntries = (
180
212
  flushIfClosed()
181
213
  },
182
214
  ToolResult: (result) => {
215
+ if (requests.has(result.callId)) return input(at, seq, userText(approvedText(result)))
216
+
183
217
  // A view that starts after the call (since a summary, say) keeps its result as text: a tool result needs its
184
218
  // call right before it.
185
219
  if (!called.has(result.callId)) return input(at, seq, userText(resultText(result)))
@@ -197,6 +231,23 @@ export const fromEntries = (
197
231
  // Every run starts in a call of the model's own (a spawn or a follow-up), whose result says so.
198
232
  SpecialistStarted: () => undefined,
199
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))))),
200
251
  ChatDeleted: () => undefined
201
252
  })
202
253
  )
@@ -232,7 +283,7 @@ export const unhandledInputs = (
232
283
  export const needsTurn = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): boolean =>
233
284
  unhandledInputs(entries).length > 0
234
285
 
235
- // 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.
236
287
  /** @internal */
237
288
  export const owesReply = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSeq: number): boolean => {
238
289
  const latest = entries.findLast(
@@ -243,7 +294,10 @@ export const owesReply = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throu
243
294
  latest !== undefined &&
244
295
  !entries.some(
245
296
  ({ event, seq }) =>
246
- 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"))
247
301
  )
248
302
  )
249
303
  }
@@ -288,10 +342,11 @@ export const owesDelivery = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, th
288
342
  )
289
343
  )
290
344
 
345
+ // A call waiting for the user's approval is what comes next, like a specialist's report.
291
346
  return (
292
347
  messages.length > 0 &&
293
348
  messages.every((message) => message.block === undefined && progress.has(message.id)) &&
294
- !after.some((event) => Predicate.isTagged(event, "TurnFailed")) &&
349
+ !after.some((event) => Predicate.isTagged(event, "TurnFailed") || Predicate.isTagged(event, "ApprovalRequested")) &&
295
350
  started.every((runId) => finished.has(runId))
296
351
  )
297
352
  }