effect-assistant 0.3.0 → 0.5.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 +66 -7
- package/dist/AssistantEvent.d.ts.map +1 -1
- package/dist/AssistantEvent.js +74 -9
- 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/Block.d.ts +63 -1
- package/dist/Block.d.ts.map +1 -1
- package/dist/Block.js +63 -2
- package/dist/Block.js.map +1 -1
- package/dist/BlockKit.d.ts +25 -3
- package/dist/BlockKit.d.ts.map +1 -1
- package/dist/BlockKit.js +14 -12
- package/dist/BlockKit.js.map +1 -1
- package/dist/Compaction.d.ts +1 -1
- package/dist/Compaction.d.ts.map +1 -1
- package/dist/Compaction.js +11 -4
- 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 +44 -6
- package/dist/Timeline.d.ts.map +1 -1
- package/dist/Timeline.js +87 -16
- 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/ToolResult.d.ts +56 -0
- package/dist/ToolResult.d.ts.map +1 -0
- package/dist/ToolResult.js +49 -0
- package/dist/ToolResult.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +6 -4
- 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 +60 -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 +11 -7
- package/dist/internal/toolkit.d.ts.map +1 -1
- package/dist/internal/toolkit.js +74 -21
- package/dist/internal/toolkit.js.map +1 -1
- package/dist/internal/tools.js +39 -6
- package/dist/internal/tools.js.map +1 -1
- package/dist/internal/turns.d.ts.map +1 -1
- package/dist/internal/turns.js +24 -16
- package/dist/internal/turns.js.map +1 -1
- package/package.json +1 -1
- package/src/Assistant.ts +7 -13
- package/src/AssistantEvent.ts +73 -8
- package/src/AssistantLoop.ts +4 -2
- package/src/Block.ts +82 -1
- package/src/BlockKit.ts +37 -4
- package/src/Compaction.ts +19 -2
- package/src/Skill.ts +134 -57
- package/src/Specialist.ts +8 -11
- package/src/Timeline.ts +120 -21
- package/src/ToolApproval.ts +249 -0
- package/src/ToolDiscovery.ts +8 -3
- package/src/ToolResult.ts +80 -0
- package/src/index.ts +10 -0
- package/src/internal/chat.ts +11 -1
- package/src/internal/history.ts +58 -3
- package/src/internal/loop.ts +138 -35
- package/src/internal/specialists.ts +7 -13
- package/src/internal/toolkit.ts +102 -35
- package/src/internal/tools.ts +81 -5
- package/src/internal/turns.ts +31 -10
- package/dist/internal/skills.js +0 -33
- package/dist/internal/skills.js.map +0 -1
- package/src/internal/skills.ts +0 -48
|
@@ -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 }) =>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shapes what the model reads of a tool's result.
|
|
3
|
+
*
|
|
4
|
+
* A tool's success goes into the chat's log in full. {@link modelView} gives the model a smaller
|
|
5
|
+
* view of it, so fields only the app's UI needs (an image URL, a file id) stay out of its context
|
|
6
|
+
* and out of what it might copy into its replies.
|
|
7
|
+
*
|
|
8
|
+
* A tool whose results the assistant's blocks show (`Block.fromToolResult`) also gets a `resultId`
|
|
9
|
+
* in what the model reads. The model passes it in a `tool_result` block to show that result, and
|
|
10
|
+
* the client draws it from the full result in the log.
|
|
11
|
+
*
|
|
12
|
+
* @since 0.5.0
|
|
13
|
+
*/
|
|
14
|
+
import type * as Tool from "effect/ai/Tool"
|
|
15
|
+
import * as Context from "effect/Context"
|
|
16
|
+
import { dual } from "effect/Function"
|
|
17
|
+
import type * as Schema from "effect/Schema"
|
|
18
|
+
|
|
19
|
+
// The tool's schemas, as `Tool.Tool` constrains them.
|
|
20
|
+
type Schemas = {
|
|
21
|
+
readonly parameters: Schema.Top
|
|
22
|
+
readonly success: Schema.Top
|
|
23
|
+
readonly failure: Schema.Top
|
|
24
|
+
readonly failureMode: Tool.FailureMode
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Annotation for what the model reads of a tool's success, set with {@link modelView}.
|
|
29
|
+
*
|
|
30
|
+
* @category annotations
|
|
31
|
+
* @since 0.5.0
|
|
32
|
+
*/
|
|
33
|
+
export class ModelView extends Context.Service<ModelView, (success: any) => Schema.Json>()(
|
|
34
|
+
"effect-assistant/ToolResult/ModelView"
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Sets what the model reads of a tool's success. The chat's log keeps the whole success.
|
|
39
|
+
*
|
|
40
|
+
* **Details**
|
|
41
|
+
*
|
|
42
|
+
* The view gets the success as the tool's success schema decodes it, and returns the JSON the
|
|
43
|
+
* model reads instead. Failures reach the model as they are.
|
|
44
|
+
*
|
|
45
|
+
* **Example** (Keeping image URLs out of the model's context)
|
|
46
|
+
*
|
|
47
|
+
* ```ts
|
|
48
|
+
* import * as Tool from "effect/ai/Tool"
|
|
49
|
+
* import * as Schema from "effect/Schema"
|
|
50
|
+
* import { ToolResult } from "effect-assistant"
|
|
51
|
+
*
|
|
52
|
+
* const Asset = Schema.Struct({ id: Schema.String, name: Schema.String, imageUrl: Schema.String })
|
|
53
|
+
*
|
|
54
|
+
* export const SearchAssets = ToolResult.modelView(
|
|
55
|
+
* Tool.make("search_assets", {
|
|
56
|
+
* parameters: Schema.Struct({ query: Schema.String }),
|
|
57
|
+
* success: Schema.Struct({ items: Schema.Array(Asset) })
|
|
58
|
+
* }),
|
|
59
|
+
* (result) => ({ items: result.items.map(({ id, name }) => ({ id, name })) })
|
|
60
|
+
* )
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* @category annotations
|
|
64
|
+
* @since 0.5.0
|
|
65
|
+
*/
|
|
66
|
+
export const modelView: {
|
|
67
|
+
<Config extends Schemas>(
|
|
68
|
+
view: (success: Config["success"]["Type"]) => Schema.Json
|
|
69
|
+
): <Name extends string, R>(tool: Tool.Tool<Name, Config, R>) => Tool.Tool<Name, Config, R>
|
|
70
|
+
<Name extends string, Config extends Schemas, R>(
|
|
71
|
+
tool: Tool.Tool<Name, Config, R>,
|
|
72
|
+
view: (success: Config["success"]["Type"]) => Schema.Json
|
|
73
|
+
): Tool.Tool<Name, Config, R>
|
|
74
|
+
} = dual(
|
|
75
|
+
2,
|
|
76
|
+
<Name extends string, Config extends Schemas, R>(
|
|
77
|
+
tool: Tool.Tool<Name, Config, R>,
|
|
78
|
+
view: (success: Config["success"]["Type"]) => Schema.Json
|
|
79
|
+
): Tool.Tool<Name, Config, R> => tool.annotate(ModelView, view)
|
|
80
|
+
)
|
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
|
*/
|
|
@@ -113,3 +118,8 @@ export * as ToolDiscovery from "./ToolDiscovery.ts"
|
|
|
113
118
|
* @since 0.1.0
|
|
114
119
|
*/
|
|
115
120
|
export * as ToolFailure from "./ToolFailure.ts"
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @since 0.5.0
|
|
124
|
+
*/
|
|
125
|
+
export * as ToolResult from "./ToolResult.ts"
|
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
|
}
|