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.
- package/dist/Assistant.d.ts +77 -37
- package/dist/Assistant.d.ts.map +1 -1
- package/dist/Assistant.js +11 -4
- package/dist/Assistant.js.map +1 -1
- package/dist/AssistantActivity.d.ts +10 -16
- package/dist/AssistantActivity.d.ts.map +1 -1
- package/dist/AssistantActivity.js +11 -4
- package/dist/AssistantActivity.js.map +1 -1
- package/dist/AssistantEngine.d.ts +39 -1
- package/dist/AssistantEngine.d.ts.map +1 -1
- package/dist/AssistantEngine.js +81 -28
- package/dist/AssistantEngine.js.map +1 -1
- package/dist/AssistantEvent.d.ts +104 -11
- package/dist/AssistantEvent.d.ts.map +1 -1
- package/dist/AssistantEvent.js +88 -13
- 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/BlockKit.d.ts +4 -4
- package/dist/BlockKit.js +1 -1
- package/dist/BlockKit.js.map +1 -1
- package/dist/ChatLog.js +1 -1
- package/dist/Compaction.d.ts +7 -3
- package/dist/Compaction.d.ts.map +1 -1
- package/dist/Compaction.js +19 -8
- package/dist/Compaction.js.map +1 -1
- package/dist/CurrentChat.d.ts +2 -1
- package/dist/CurrentChat.d.ts.map +1 -1
- package/dist/CurrentChat.js.map +1 -1
- package/dist/Instructions.js +1 -1
- package/dist/Skill.d.ts +86 -11
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +93 -18
- package/dist/Skill.js.map +1 -1
- package/dist/Specialist.d.ts +7 -7
- package/dist/Specialist.d.ts.map +1 -1
- package/dist/Specialist.js.map +1 -1
- package/dist/Timeline.d.ts +516 -0
- package/dist/Timeline.d.ts.map +1 -0
- package/dist/Timeline.js +631 -0
- package/dist/Timeline.js.map +1 -0
- 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 +4 -4
- package/dist/index.js +4 -4
- package/dist/internal/chat.js +9 -7
- package/dist/internal/chat.js.map +1 -1
- package/dist/internal/events.d.ts +15 -16
- package/dist/internal/events.d.ts.map +1 -1
- package/dist/internal/events.js +29 -28
- package/dist/internal/events.js.map +1 -1
- package/dist/internal/history.js +53 -22
- package/dist/internal/history.js.map +1 -1
- package/dist/internal/input.js +7 -0
- package/dist/internal/input.js.map +1 -0
- package/dist/internal/loop.d.ts +10 -5
- package/dist/internal/loop.d.ts.map +1 -1
- package/dist/internal/loop.js +55 -22
- package/dist/internal/loop.js.map +1 -1
- package/dist/internal/specialists.js +10 -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/tools.js +1 -1
- package/dist/internal/turns.d.ts.map +1 -1
- package/dist/internal/turns.js +35 -42
- package/dist/internal/turns.js.map +1 -1
- package/package.json +1 -1
- package/src/Assistant.ts +80 -40
- package/src/AssistantActivity.ts +11 -18
- package/src/AssistantEngine.ts +110 -43
- package/src/AssistantEvent.ts +106 -14
- package/src/AssistantLoop.ts +4 -2
- package/src/BlockKit.ts +6 -6
- package/src/Compaction.ts +25 -4
- package/src/CurrentChat.ts +2 -1
- package/src/Skill.ts +133 -23
- package/src/Specialist.ts +9 -9
- package/src/Timeline.ts +947 -0
- package/src/ToolApproval.ts +249 -0
- package/src/ToolDiscovery.ts +8 -3
- package/src/index.ts +10 -10
- package/src/internal/chat.ts +15 -5
- package/src/internal/events.ts +43 -43
- package/src/internal/history.ts +94 -32
- package/src/internal/input.ts +3 -0
- package/src/internal/loop.ts +120 -34
- package/src/internal/specialists.ts +7 -12
- package/src/internal/toolkit.ts +47 -30
- package/src/internal/turns.ts +52 -58
- package/dist/AppEvent.d.ts +0 -111
- package/dist/AppEvent.d.ts.map +0 -1
- package/dist/AppEvent.js +0 -71
- package/dist/AppEvent.js.map +0 -1
- package/dist/ChatView.d.ts +0 -408
- package/dist/ChatView.d.ts.map +0 -1
- package/dist/ChatView.js +0 -443
- package/dist/ChatView.js.map +0 -1
- package/dist/internal/skills.js +0 -28
- package/dist/internal/skills.js.map +0 -1
- package/src/AppEvent.ts +0 -134
- package/src/ChatView.ts +0 -587
- package/src/internal/skills.ts +0 -37
|
@@ -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
|
@@ -4,11 +4,6 @@
|
|
|
4
4
|
* @since 0.1.0
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* @since 0.1.0
|
|
9
|
-
*/
|
|
10
|
-
export * as AppEvent from "./AppEvent.ts"
|
|
11
|
-
|
|
12
7
|
/**
|
|
13
8
|
* @since 0.1.0
|
|
14
9
|
*/
|
|
@@ -64,11 +59,6 @@ export * as ChatLog from "./ChatLog.ts"
|
|
|
64
59
|
*/
|
|
65
60
|
export * as Chats from "./Chats.ts"
|
|
66
61
|
|
|
67
|
-
/**
|
|
68
|
-
* @since 0.1.0
|
|
69
|
-
*/
|
|
70
|
-
export * as ChatView from "./ChatView.ts"
|
|
71
|
-
|
|
72
62
|
/**
|
|
73
63
|
* @since 0.1.0
|
|
74
64
|
*/
|
|
@@ -109,6 +99,16 @@ export * as Skill from "./Skill.ts"
|
|
|
109
99
|
*/
|
|
110
100
|
export * as Specialist from "./Specialist.ts"
|
|
111
101
|
|
|
102
|
+
/**
|
|
103
|
+
* @since 0.3.0
|
|
104
|
+
*/
|
|
105
|
+
export * as Timeline from "./Timeline.ts"
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @since 0.4.0
|
|
109
|
+
*/
|
|
110
|
+
export * as ToolApproval from "./ToolApproval.ts"
|
|
111
|
+
|
|
112
112
|
/**
|
|
113
113
|
* @since 0.1.0
|
|
114
114
|
*/
|
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"
|
|
@@ -101,7 +102,6 @@ export const make = Effect.fnUntraced(function* (options: {
|
|
|
101
102
|
}) {
|
|
102
103
|
const { registration, chatId } = options
|
|
103
104
|
const { app } = registration.definition
|
|
104
|
-
const { wakes } = app
|
|
105
105
|
const storage = yield* ChatLog
|
|
106
106
|
const chats = yield* Chats.Chats
|
|
107
107
|
const initial = yield* storage.read(chatId)
|
|
@@ -198,10 +198,20 @@ export const make = Effect.fnUntraced(function* (options: {
|
|
|
198
198
|
if (yield* isDeleted) return yield* refused(input, "is deleted")
|
|
199
199
|
|
|
200
200
|
const id = idOf(input)
|
|
201
|
+
const all = yield* Ref.get(entries)
|
|
201
202
|
|
|
202
|
-
if (id !== undefined &&
|
|
203
|
+
if (id !== undefined && all.some(({ event }) => idOf(event) === id)) return
|
|
203
204
|
|
|
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
|
+
}
|
|
212
|
+
|
|
213
|
+
// Stored as an input: it came in from outside a turn, so it wakes one, also after a restart.
|
|
214
|
+
yield* write([AssistantEvent.isLibraryEvent(input) ? input : AppEvents.markInput(input)])
|
|
205
215
|
})
|
|
206
216
|
)
|
|
207
217
|
})
|
|
@@ -232,7 +242,7 @@ export const make = Effect.fnUntraced(function* (options: {
|
|
|
232
242
|
|
|
233
243
|
const drain: Effect.Effect<void> = Semaphore.withPermit(turnsLock)(
|
|
234
244
|
Effect.gen(function* () {
|
|
235
|
-
while (!(yield* isDeleted) && History.needsTurn(yield* chat.entries
|
|
245
|
+
while (!(yield* isDeleted) && History.needsTurn(yield* chat.entries)) {
|
|
236
246
|
yield* Turns.run({ registration, handler, chat, services, checkOpen: Effect.asVoid(checkAvailable) })
|
|
237
247
|
}
|
|
238
248
|
})
|
|
@@ -296,7 +306,7 @@ export const make = Effect.fnUntraced(function* (options: {
|
|
|
296
306
|
yield* inBackground(drain)
|
|
297
307
|
}),
|
|
298
308
|
resume: Effect.gen(function* () {
|
|
299
|
-
if (History.needsTurn(yield* chat.entries
|
|
309
|
+
if (History.needsTurn(yield* chat.entries)) yield* inBackground(drain)
|
|
300
310
|
}),
|
|
301
311
|
stream: (after) =>
|
|
302
312
|
Stream.unwrap(
|
package/src/internal/events.ts
CHANGED
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
import * as Effect from "effect/Effect"
|
|
2
2
|
import * as Option from "effect/Option"
|
|
3
|
+
import * as Predicate from "effect/Predicate"
|
|
4
|
+
import * as Record from "effect/Record"
|
|
3
5
|
import * as Schema from "effect/Schema"
|
|
4
|
-
import
|
|
6
|
+
import * as SchemaAST from "effect/SchemaAST"
|
|
5
7
|
import type * as Assistant from "../Assistant.ts"
|
|
6
8
|
import * as AssistantEvent from "../AssistantEvent.ts"
|
|
7
|
-
import {
|
|
9
|
+
import { inputKey } from "./input.ts"
|
|
8
10
|
|
|
9
11
|
/** @internal */
|
|
10
12
|
export interface AppEvents {
|
|
11
|
-
readonly byTag: ReadonlyMap<string,
|
|
12
|
-
readonly wakes: ReadonlySet<string>
|
|
13
|
+
readonly byTag: ReadonlyMap<string, AssistantEvent.AppEventSchema>
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/** @internal */
|
|
17
|
+
export const tagOf = (schema: AssistantEvent.AppEventSchema): string => {
|
|
18
|
+
const ast = schema.fields._tag.ast
|
|
19
|
+
|
|
20
|
+
if (SchemaAST.isLiteral(ast) && Predicate.isString(ast.literal)) return ast.literal
|
|
21
|
+
|
|
22
|
+
throw new Error("An app event is a tagged struct or class.")
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
/** @internal */
|
|
21
|
-
export const make = (events: ReadonlyArray<
|
|
22
|
-
const byTag = new Map(
|
|
23
|
-
events.map((event): [string, Registered] => [event.tag, { schema: event.schema, render: event.render }])
|
|
24
|
-
)
|
|
26
|
+
export const make = (events: ReadonlyArray<AssistantEvent.AppEventSchema>): AppEvents => {
|
|
27
|
+
const byTag = new Map(events.map((schema): [string, AssistantEvent.AppEventSchema] => [tagOf(schema), schema]))
|
|
25
28
|
|
|
26
29
|
for (const tag of byTag.keys()) {
|
|
27
30
|
if (AssistantEvent.isLibraryEvent({ _tag: tag })) throw new Error(`${tag} is one of the library's events.`)
|
|
@@ -29,17 +32,17 @@ export const make = (events: ReadonlyArray<AppEvent.Any>): AppEvents => {
|
|
|
29
32
|
|
|
30
33
|
if (byTag.size !== events.length) throw new Error("An assistant's events have different tags.")
|
|
31
34
|
|
|
32
|
-
return { byTag
|
|
35
|
+
return { byTag }
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/** @internal */
|
|
36
39
|
export const encode = Effect.fnUntraced(function* (app: AppEvents, event: { readonly _tag: string }) {
|
|
37
|
-
const
|
|
40
|
+
const schema = app.byTag.get(event._tag)
|
|
38
41
|
|
|
39
42
|
// The types only let registered events through, so one with no schema here is a defect.
|
|
40
|
-
if (
|
|
43
|
+
if (schema === undefined) return yield* Effect.die(`${event._tag} isn't one of this assistant's events.`)
|
|
41
44
|
|
|
42
|
-
const json = yield* Schema.encodeUnknownEffect(Schema.toCodecJson(
|
|
45
|
+
const json = yield* Schema.encodeUnknownEffect(Schema.toCodecJson(schema))(event)
|
|
43
46
|
|
|
44
47
|
return yield* Schema.decodeUnknownEffect(AssistantEvent.StoredAppEvent)(json)
|
|
45
48
|
}, Effect.orDie)
|
|
@@ -47,6 +50,30 @@ export const encode = Effect.fnUntraced(function* (app: AppEvents, event: { read
|
|
|
47
50
|
/** @internal */
|
|
48
51
|
export const isRegistered = (app: AppEvents, event: { readonly _tag: string }): boolean => app.byTag.has(event._tag)
|
|
49
52
|
|
|
53
|
+
// The app's schema decodes the event as the app wrote it.
|
|
54
|
+
const unmarked = (event: { readonly _tag: string }) =>
|
|
55
|
+
Predicate.hasProperty(event, inputKey) ? Record.remove(event, inputKey) : event
|
|
56
|
+
|
|
57
|
+
// An event as the log stores it, decoded with its schema.
|
|
58
|
+
/** @internal */
|
|
59
|
+
export const decodeWith = (
|
|
60
|
+
schema: AssistantEvent.AppEventSchema,
|
|
61
|
+
event: { readonly _tag: string }
|
|
62
|
+
): Option.Option<{ readonly _tag: string }> => Schema.decodeUnknownOption(Schema.toCodecJson(schema))(unmarked(event))
|
|
63
|
+
|
|
64
|
+
const decodeEvent = (app: AppEvents, event: { readonly _tag: string }): Option.Option<{ readonly _tag: string }> => {
|
|
65
|
+
const schema = app.byTag.get(event._tag)
|
|
66
|
+
|
|
67
|
+
return schema === undefined ? Option.none() : decodeWith(schema, event)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// What came in through `submit`, from outside a turn, is an input: it wakes a turn.
|
|
71
|
+
/** @internal */
|
|
72
|
+
export const markInput = (event: AssistantEvent.StoredAppEvent): AssistantEvent.StoredAppEvent => ({
|
|
73
|
+
...event,
|
|
74
|
+
[inputKey]: true
|
|
75
|
+
})
|
|
76
|
+
|
|
50
77
|
// An app event that doesn't decode (say, one from an older version of its schema) is left out.
|
|
51
78
|
/** @internal */
|
|
52
79
|
export const decodeEntry = (app: AppEvents, entry: AssistantEvent.AnyEntry): Option.Option<Assistant.Entry<any>> => {
|
|
@@ -54,14 +81,7 @@ export const decodeEntry = (app: AppEvents, entry: AssistantEvent.AnyEntry): Opt
|
|
|
54
81
|
|
|
55
82
|
if (AssistantEvent.isLibraryEvent(event)) return Option.some({ ...toEntry(entry), event })
|
|
56
83
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (registered === undefined) return Option.none()
|
|
60
|
-
|
|
61
|
-
return Option.map(Schema.decodeUnknownOption(Schema.toCodecJson(registered.schema))(event), (decoded) => ({
|
|
62
|
-
...toEntry(entry),
|
|
63
|
-
event: decoded
|
|
64
|
-
}))
|
|
84
|
+
return Option.map(decodeEvent(app, event), (decoded) => ({ ...toEntry(entry), event: decoded }))
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
/** @internal */
|
|
@@ -73,28 +93,8 @@ export const decodeEntries = (
|
|
|
73
93
|
/** @internal */
|
|
74
94
|
export type DecodedEntries = ReturnType<typeof decodeEntries>
|
|
75
95
|
|
|
76
|
-
const decodeStored = Schema.decodeUnknownOption(AssistantEvent.StoredAppEvent)
|
|
77
|
-
|
|
78
96
|
const toEntry = (entry: AssistantEvent.AnyEntry) => ({
|
|
79
97
|
chatId: entry.chatId,
|
|
80
98
|
seq: entry.seq,
|
|
81
99
|
at: entry.at
|
|
82
100
|
})
|
|
83
|
-
|
|
84
|
-
/** @internal */
|
|
85
|
-
export const read = (app: AppEvents, event: { readonly _tag: string }): string | undefined => {
|
|
86
|
-
const registered = app.byTag.get(event._tag)
|
|
87
|
-
|
|
88
|
-
if (registered?.render === undefined) return undefined
|
|
89
|
-
|
|
90
|
-
if (registered.render !== "json") return registered.render(event)
|
|
91
|
-
|
|
92
|
-
const stored = Schema.encodeUnknownOption(Schema.toCodecJson(registered.schema))(event).pipe(
|
|
93
|
-
Option.flatMap(decodeStored)
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
return Option.match(stored, {
|
|
97
|
-
onNone: () => undefined,
|
|
98
|
-
onSome: ({ _tag, ...fields }) => `<${_tag}>\n${toJsonText(fields)}\n</${_tag}>`
|
|
99
|
-
})
|
|
100
|
-
}
|