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.
- package/dist/Assistant.d.ts +5 -11
- package/dist/Assistant.d.ts.map +1 -1
- package/dist/Assistant.js.map +1 -1
- package/dist/AssistantEvent.d.ts +55 -7
- package/dist/AssistantEvent.d.ts.map +1 -1
- package/dist/AssistantEvent.js +61 -7
- package/dist/AssistantEvent.js.map +1 -1
- package/dist/AssistantLoop.d.ts +4 -2
- package/dist/AssistantLoop.d.ts.map +1 -1
- package/dist/AssistantLoop.js +4 -2
- package/dist/AssistantLoop.js.map +1 -1
- package/dist/Compaction.d.ts +1 -1
- package/dist/Compaction.d.ts.map +1 -1
- package/dist/Compaction.js +13 -6
- package/dist/Compaction.js.map +1 -1
- package/dist/Skill.d.ts +85 -43
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +93 -44
- package/dist/Skill.js.map +1 -1
- package/dist/Specialist.d.ts +6 -9
- package/dist/Specialist.d.ts.map +1 -1
- package/dist/Specialist.js.map +1 -1
- package/dist/Timeline.d.ts +40 -6
- package/dist/Timeline.d.ts.map +1 -1
- package/dist/Timeline.js +74 -15
- package/dist/Timeline.js.map +1 -1
- package/dist/ToolApproval.d.ts +146 -0
- package/dist/ToolApproval.d.ts.map +1 -0
- package/dist/ToolApproval.js +201 -0
- package/dist/ToolApproval.js.map +1 -0
- package/dist/ToolDiscovery.d.ts +6 -4
- package/dist/ToolDiscovery.d.ts.map +1 -1
- package/dist/ToolDiscovery.js +5 -4
- package/dist/ToolDiscovery.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/internal/chat.js +4 -1
- package/dist/internal/chat.js.map +1 -1
- package/dist/internal/history.js +28 -3
- package/dist/internal/history.js.map +1 -1
- package/dist/internal/loop.d.ts +10 -5
- package/dist/internal/loop.d.ts.map +1 -1
- package/dist/internal/loop.js +54 -21
- package/dist/internal/loop.js.map +1 -1
- package/dist/internal/specialists.js +9 -16
- package/dist/internal/specialists.js.map +1 -1
- package/dist/internal/toolkit.d.ts +5 -7
- package/dist/internal/toolkit.d.ts.map +1 -1
- package/dist/internal/toolkit.js +28 -18
- package/dist/internal/toolkit.js.map +1 -1
- package/dist/internal/turns.d.ts.map +1 -1
- package/dist/internal/turns.js +18 -13
- package/dist/internal/turns.js.map +1 -1
- package/package.json +1 -1
- package/src/Assistant.ts +7 -13
- package/src/AssistantEvent.ts +60 -6
- package/src/AssistantLoop.ts +4 -2
- package/src/Compaction.ts +19 -2
- package/src/Skill.ts +134 -57
- package/src/Specialist.ts +8 -11
- package/src/Timeline.ts +104 -18
- package/src/ToolApproval.ts +249 -0
- package/src/ToolDiscovery.ts +8 -3
- package/src/index.ts +5 -0
- package/src/internal/chat.ts +11 -1
- package/src/internal/history.ts +58 -3
- package/src/internal/loop.ts +120 -34
- package/src/internal/specialists.ts +7 -13
- package/src/internal/toolkit.ts +47 -30
- package/src/internal/turns.ts +24 -7
- package/dist/internal/skills.js +0 -33
- package/dist/internal/skills.js.map +0 -1
- 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,
|
|
6
|
-
*
|
|
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
|
-
*
|
|
139
|
-
*
|
|
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(
|
|
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`.
|
|
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<
|
|
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,
|
|
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
|
|
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(
|
|
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
|
|
711
|
-
failed ===
|
|
712
|
-
deleted ===
|
|
713
|
-
sameActivity(
|
|
796
|
+
return all.length === 0 &&
|
|
797
|
+
failed === previous.failed &&
|
|
798
|
+
deleted === previous.deleted &&
|
|
799
|
+
sameActivity(previous.activity, now)
|
|
714
800
|
? undefined
|
|
715
|
-
: { seq, items:
|
|
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,
|
|
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
|
+
})
|
package/src/ToolDiscovery.ts
CHANGED
|
@@ -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
|
|
98
|
-
*
|
|
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)
|
|
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
package/src/internal/chat.ts
CHANGED
|
@@ -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 &&
|
|
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)])
|
package/src/internal/history.ts
CHANGED
|
@@ -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 &&
|
|
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
|
}
|