effect-assistant 0.1.0 → 0.3.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 (131) hide show
  1. package/README.md +1 -1
  2. package/dist/Assistant.d.ts +74 -28
  3. package/dist/Assistant.d.ts.map +1 -1
  4. package/dist/Assistant.js +11 -4
  5. package/dist/Assistant.js.map +1 -1
  6. package/dist/AssistantActivity.d.ts +10 -16
  7. package/dist/AssistantActivity.d.ts.map +1 -1
  8. package/dist/AssistantActivity.js +11 -4
  9. package/dist/AssistantActivity.js.map +1 -1
  10. package/dist/AssistantEngine.d.ts +39 -1
  11. package/dist/AssistantEngine.d.ts.map +1 -1
  12. package/dist/AssistantEngine.js +81 -28
  13. package/dist/AssistantEngine.js.map +1 -1
  14. package/dist/AssistantEvent.d.ts +52 -70
  15. package/dist/AssistantEvent.d.ts.map +1 -1
  16. package/dist/AssistantEvent.js +28 -57
  17. package/dist/AssistantEvent.js.map +1 -1
  18. package/dist/AssistantLoop.d.ts +7 -5
  19. package/dist/AssistantLoop.d.ts.map +1 -1
  20. package/dist/AssistantLoop.js +6 -3
  21. package/dist/AssistantLoop.js.map +1 -1
  22. package/dist/Block.js.map +1 -1
  23. package/dist/BlockKit.d.ts +14 -18
  24. package/dist/BlockKit.d.ts.map +1 -1
  25. package/dist/BlockKit.js +4 -7
  26. package/dist/BlockKit.js.map +1 -1
  27. package/dist/ChatLog.js +1 -1
  28. package/dist/Compaction.d.ts +8 -4
  29. package/dist/Compaction.d.ts.map +1 -1
  30. package/dist/Compaction.js +12 -15
  31. package/dist/Compaction.js.map +1 -1
  32. package/dist/CurrentChat.d.ts +2 -1
  33. package/dist/CurrentChat.d.ts.map +1 -1
  34. package/dist/CurrentChat.js.map +1 -1
  35. package/dist/Instructions.js +1 -1
  36. package/dist/Skill.d.ts +33 -0
  37. package/dist/Skill.d.ts.map +1 -1
  38. package/dist/Skill.js +27 -1
  39. package/dist/Skill.js.map +1 -1
  40. package/dist/Specialist.d.ts +4 -1
  41. package/dist/Specialist.d.ts.map +1 -1
  42. package/dist/Specialist.js.map +1 -1
  43. package/dist/Timeline.d.ts +482 -0
  44. package/dist/Timeline.d.ts.map +1 -0
  45. package/dist/Timeline.js +572 -0
  46. package/dist/Timeline.js.map +1 -0
  47. package/dist/ToolDiscovery.d.ts +4 -5
  48. package/dist/ToolDiscovery.d.ts.map +1 -1
  49. package/dist/ToolDiscovery.js +5 -4
  50. package/dist/ToolDiscovery.js.map +1 -1
  51. package/dist/ToolFailure.d.ts +1 -1
  52. package/dist/ToolFailure.js +1 -1
  53. package/dist/ToolFailure.js.map +1 -1
  54. package/dist/index.d.ts +2 -5
  55. package/dist/index.js +4 -7
  56. package/dist/internal/chat.js +11 -37
  57. package/dist/internal/chat.js.map +1 -1
  58. package/dist/internal/events.d.ts +15 -16
  59. package/dist/internal/events.d.ts.map +1 -1
  60. package/dist/internal/events.js +29 -28
  61. package/dist/internal/events.js.map +1 -1
  62. package/dist/internal/history.js +26 -58
  63. package/dist/internal/history.js.map +1 -1
  64. package/dist/internal/input.js +7 -0
  65. package/dist/internal/input.js.map +1 -0
  66. package/dist/internal/loop.d.ts +2 -2
  67. package/dist/internal/loop.d.ts.map +1 -1
  68. package/dist/internal/loop.js +21 -56
  69. package/dist/internal/loop.js.map +1 -1
  70. package/dist/internal/skills.js +6 -1
  71. package/dist/internal/skills.js.map +1 -1
  72. package/dist/internal/specialists.js +7 -7
  73. package/dist/internal/specialists.js.map +1 -1
  74. package/dist/internal/toolkit.d.ts +6 -20
  75. package/dist/internal/toolkit.d.ts.map +1 -1
  76. package/dist/internal/toolkit.js +10 -31
  77. package/dist/internal/toolkit.js.map +1 -1
  78. package/dist/internal/tools.js +2 -21
  79. package/dist/internal/tools.js.map +1 -1
  80. package/dist/internal/turns.d.ts.map +1 -1
  81. package/dist/internal/turns.js +24 -43
  82. package/dist/internal/turns.js.map +1 -1
  83. package/package.json +3 -2
  84. package/src/Assistant.ts +75 -29
  85. package/src/AssistantActivity.ts +11 -18
  86. package/src/AssistantEngine.ts +110 -43
  87. package/src/AssistantEvent.ts +46 -74
  88. package/src/AssistantLoop.ts +7 -5
  89. package/src/Block.ts +1 -1
  90. package/src/BlockKit.ts +18 -31
  91. package/src/Compaction.ts +8 -21
  92. package/src/CurrentChat.ts +2 -1
  93. package/src/Skill.ts +34 -1
  94. package/src/Specialist.ts +4 -1
  95. package/src/Timeline.ts +861 -0
  96. package/src/ToolDiscovery.ts +5 -13
  97. package/src/ToolFailure.ts +1 -1
  98. package/src/index.ts +5 -20
  99. package/src/internal/chat.ts +8 -57
  100. package/src/internal/events.ts +43 -43
  101. package/src/internal/history.ts +36 -101
  102. package/src/internal/input.ts +3 -0
  103. package/src/internal/loop.ts +31 -101
  104. package/src/internal/skills.ts +11 -0
  105. package/src/internal/specialists.ts +6 -9
  106. package/src/internal/toolkit.ts +17 -60
  107. package/src/internal/tools.ts +2 -53
  108. package/src/internal/turns.ts +37 -79
  109. package/dist/AppEvent.d.ts +0 -111
  110. package/dist/AppEvent.d.ts.map +0 -1
  111. package/dist/AppEvent.js +0 -71
  112. package/dist/AppEvent.js.map +0 -1
  113. package/dist/ChatView.d.ts +0 -459
  114. package/dist/ChatView.d.ts.map +0 -1
  115. package/dist/ChatView.js +0 -496
  116. package/dist/ChatView.js.map +0 -1
  117. package/dist/Decision.d.ts +0 -145
  118. package/dist/Decision.d.ts.map +0 -1
  119. package/dist/Decision.js +0 -178
  120. package/dist/Decision.js.map +0 -1
  121. package/dist/DecisionId.d.ts +0 -37
  122. package/dist/DecisionId.d.ts.map +0 -1
  123. package/dist/DecisionId.js +0 -44
  124. package/dist/DecisionId.js.map +0 -1
  125. package/dist/internal/decisions.js +0 -66
  126. package/dist/internal/decisions.js.map +0 -1
  127. package/src/AppEvent.ts +0 -134
  128. package/src/ChatView.ts +0 -658
  129. package/src/Decision.ts +0 -241
  130. package/src/DecisionId.ts +0 -46
  131. package/src/internal/decisions.ts +0 -131
@@ -14,20 +14,14 @@ import * as Predicate from "effect/Predicate"
14
14
  import * as Schema from "effect/Schema"
15
15
  import * as Stream from "effect/Stream"
16
16
  import * as AssistantEvent from "../AssistantEvent.ts"
17
- import * as DecisionId from "../DecisionId.ts"
18
17
  import type * as Instructions from "../Instructions.ts"
19
- import * as Decisions from "./decisions.ts"
20
18
  import * as History from "./history.ts"
21
19
  import * as Telemetry from "./telemetry.ts"
22
20
  import * as Toolkits from "./toolkit.ts"
23
21
  import { isTurnEnding } from "./tools.ts"
24
22
 
25
23
  /** @internal */
26
- export type Event =
27
- | AssistantEvent.ModelOutput
28
- | AssistantEvent.ToolStarted
29
- | AssistantEvent.ToolResult
30
- | AssistantEvent.DecisionRequested
24
+ export type Event = AssistantEvent.ModelOutput | AssistantEvent.ToolStarted | AssistantEvent.ToolResult
31
25
 
32
26
  // The assistant's own tools with the turn's services, so an app's own loop in a turn can message the user. A
33
27
  // specialist's run has none.
@@ -79,10 +73,7 @@ export interface StepResult {
79
73
 
80
74
  // The loop runs the calls itself, after storing each as started, so the model call runs none.
81
75
  const requestToolkit = (definitions: ReadonlyArray<Tool.Any>): Toolkit.WithHandler<Record<string, Tool.Any>> => ({
82
- // The loop decides which calls wait for the user's decision; the model call would ask for approvals of its own.
83
- tools: Object.fromEntries(
84
- definitions.map((tool) => [tool.name, tool.needsApproval === undefined ? tool : tool.setNeedsApproval(false)])
85
- ),
76
+ tools: Object.fromEntries(definitions.map((tool) => [tool.name, tool])),
86
77
  handle: () => Effect.succeed(Stream.empty)
87
78
  })
88
79
 
@@ -120,73 +111,28 @@ const runCall = Effect.fnUntraced(function* <R>(tools: Toolkits.Tools<R>, call:
120
111
  return AssistantEvent.ToolResult.make({ callId: call.id, tool: call.name, ...result })
121
112
  })
122
113
 
123
- interface Pending {
124
- readonly call: AssistantEvent.ToolCallPart
125
- readonly approved: boolean
126
- }
127
-
128
- // Only a turn parks: a specialist's call fails instead.
129
- const park = Effect.fnUntraced(
130
- function* (call: AssistantEvent.ToolCallPart) {
131
- if (Option.isNone(yield* Decisions.TurnDecisions)) {
132
- return AssistantEvent.ToolResult.make({
133
- callId: call.id,
134
- tool: call.name,
135
- ...Toolkits.failed(
136
- `${call.name} needs the user's decision, and only the assistant can ask them. Don't run it: say in your answer what needs deciding, with the details.`,
137
- "NeedsDecision"
138
- )
139
- })
140
- }
141
-
142
- return AssistantEvent.DecisionRequested.make({
143
- id: DecisionId.fromCallId(call.id),
144
- origin: AssistantEvent.CallOrigin.make({ callId: call.id, tool: call.name, params: call.params })
145
- })
146
- },
147
- (effect, call) => effect.pipe(Telemetry.toolSpan(call))
148
- )
149
-
150
- // Every `ToolStarted` is stored before any call runs. `then` is told whether the model has to read the results: one
151
- // failed, or waits for the user's decision.
114
+ // Every `ToolStarted` is stored before any call runs. `then` is told whether one of the calls failed, so the model
115
+ // has to read the results.
152
116
  const runCalls = <R, S, E, R2>(
153
117
  tools: Toolkits.Tools<R>,
154
- pending: ReadonlyArray<Pending>,
155
- history: ReadonlyArray<Prompt.Message>,
156
- then: (reread: boolean) => Stream.Stream<Step<never, S>, E, R2>
118
+ calls: ReadonlyArray<AssistantEvent.ToolCallPart>,
119
+ then: (failed: boolean) => Stream.Stream<Step<never, S>, E, R2>
157
120
  ): Stream.Stream<Step<Event, S>, E, R | R2> =>
158
- Stream.unwrap(
159
- Effect.gen(function* () {
160
- const parks = yield* Effect.forEach(pending, ({ call, approved }) =>
161
- approved ? Effect.succeed(false) : Toolkits.needsDecisionOf(tools, call, history)
162
- )
163
-
164
- const started = pending.flatMap(({ call }, i) =>
165
- parks[i] ? [] : [value(AssistantEvent.ToolStarted.make({ callId: call.id, tool: call.name }))]
166
- )
167
-
168
- const results = Effect.forEach(pending, ({ call }, i) => (parks[i] ? park(call) : runCall(tools, call)), {
169
- concurrency: "unbounded"
170
- })
171
-
172
- const reread = (done: ReadonlyArray<Event>) =>
173
- done.some((event) => Predicate.isTagged(event, "DecisionRequested") || isFailure(event))
174
-
175
- return Stream.fromIterable(started).pipe(
176
- Stream.concat(
177
- Stream.unwrap(
178
- Effect.gen(function* () {
179
- const done = yield* results
180
-
181
- return Stream.fromIterable(done.map(value)).pipe(Stream.concat(then(reread(done))))
182
- })
183
- )
184
- )
121
+ Stream.fromIterable(
122
+ calls.map((call) => value(AssistantEvent.ToolStarted.make({ callId: call.id, tool: call.name })))
123
+ ).pipe(
124
+ Stream.concat(
125
+ Stream.unwrap(
126
+ Effect.gen(function* () {
127
+ const done = yield* Effect.forEach(calls, (call) => runCall(tools, call), { concurrency: "unbounded" })
128
+
129
+ return Stream.fromIterable(done.map(value)).pipe(Stream.concat(then(done.some(isFailure))))
130
+ })
185
131
  )
186
- })
132
+ )
187
133
  )
188
134
 
189
- const isFailure = (event: Event) => Predicate.isTagged(event, "ToolResult") && event.outcome === "failure"
135
+ const isFailure = (result: AssistantEvent.ToolResult) => result.outcome === "failure"
190
136
 
191
137
  /** @internal */
192
138
  export type Step<A, S> = Data.TaggedEnum<{
@@ -246,6 +192,9 @@ export const step = <R, S, E2 = never, R2 = never>(
246
192
  const instructions = Predicate.isString(options.instructions) ? { text: options.instructions } : options.instructions
247
193
 
248
194
  const after = (result: StepResult) => Stream.suspend(() => then(result))
195
+ const needingApproval = Toolkits.needingApproval(toolkit.definitions)
196
+
197
+ if (needingApproval.length > 0) return Stream.die(Toolkits.needsApprovalMessage(needingApproval))
249
198
 
250
199
  return Stream.unwrap(
251
200
  Effect.gen(function* () {
@@ -253,23 +202,9 @@ export const step = <R, S, E2 = never, R2 = never>(
253
202
  // Calls a crash cut off, with no result: they run again, with the same ids, before the model goes on.
254
203
  const cutOff = History.unansweredCalls(history)
255
204
 
256
- // Calls the user approved: they run as the model made them, before the model goes on. Not in a loop without them.
257
- const decisions = yield* Decisions.TurnDecisions
258
-
259
- const approved = Option.isNone(decisions)
260
- ? []
261
- : (yield* decisions.value.approved).filter((call) =>
262
- toolkit.definitions.some((tool) => tool.name === call.name)
263
- )
264
-
265
- if (cutOff.length > 0 || approved.length > 0) {
266
- const pending = [
267
- ...cutOff.map((call) => ({ call, approved: false })),
268
- ...approved.map((call) => ({ call, approved: true }))
269
- ]
270
-
271
- return runCalls(toolkit, pending, history, () =>
272
- after({ output: undefined, done: false, historyLength: history.length + pending.length })
205
+ if (cutOff.length > 0) {
206
+ return runCalls(toolkit, cutOff, () =>
207
+ after({ output: undefined, done: false, historyLength: history.length + cutOff.length })
273
208
  )
274
209
  }
275
210
 
@@ -295,20 +230,15 @@ export const step = <R, S, E2 = never, R2 = never>(
295
230
  const calls = toolCalls(output.parts)
296
231
  const historyLength = history.length + Prompt.fromResponseParts(output.parts).content.length + calls.length
297
232
 
298
- // A failed call doesn't end the turn: a message that failed never reached the user, so the model reads why. Nor
299
- // does a parked one: the model asks the user for the decision it waits for.
233
+ // A failed call doesn't end the turn: a message that failed never reached the user, so the model reads why.
300
234
  return Stream.make(value(output)).pipe(
301
235
  Stream.concat(
302
- runCalls(
303
- toolkit,
304
- calls.map((call) => ({ call, approved: false })),
305
- history,
306
- (reread) =>
307
- after({
308
- output,
309
- done: calls.length === 0 || (!reread && calls.some(isTurnEnding)),
310
- historyLength
311
- })
236
+ runCalls(toolkit, calls, (failed) =>
237
+ after({
238
+ output,
239
+ done: calls.length === 0 || (!failed && calls.some(isTurnEnding)),
240
+ historyLength
241
+ })
312
242
  )
313
243
  )
314
244
  )
@@ -8,6 +8,17 @@ import type { Skill } from "../Skill.ts"
8
8
  import * as ToolFailure from "../ToolFailure.ts"
9
9
  import * as Toolkits from "./toolkit.ts"
10
10
 
11
+ // A skill whose tools the loop doesn't have would send the model after calls it can't make.
12
+ /** @internal */
13
+ export const available = <R>(
14
+ skills: ReadonlyArray<Skill<R>>,
15
+ definitions: ReadonlyArray<Tool.Any>
16
+ ): ReadonlyArray<Skill<R>> => {
17
+ const names = new Set(definitions.map((tool) => tool.name))
18
+
19
+ return skills.filter((skill) => (skill.tools ?? []).every((tool) => names.has(tool.name)))
20
+ }
21
+
11
22
  /** @internal */
12
23
  export const toTools = <R>(skills: ReadonlyArray<Skill<R>>): Toolkits.Tools<R> => {
13
24
  if (skills.length === 0) return Toolkits.empty
@@ -26,7 +26,6 @@ import * as Skill from "../Skill.ts"
26
26
  import * as Skills from "./skills.ts"
27
27
  import * as Specialist from "../Specialist.ts"
28
28
  import * as ToolFailure from "../ToolFailure.ts"
29
- import * as Decisions from "./decisions.ts"
30
29
  import { toolMessage, userText } from "./history.ts"
31
30
  import { mapText } from "./instructions.ts"
32
31
  import * as Telemetry from "./telemetry.ts"
@@ -85,21 +84,21 @@ export const runBrief = Effect.fnUntraced(
85
84
  brief: options.brief,
86
85
  history: current,
87
86
  loop: (loop) => {
88
- const skills = loop.skills ?? []
87
+ const own = Toolkits.fromToolkitOrEmpty(loop.toolkit)
88
+ const skills = Skills.available(loop.skills ?? [], own.definitions)
89
89
 
90
90
  return Loop.run({
91
91
  instructions: mapText(loop.instructions, (text) => Skill.withCatalog(text, skills)),
92
- toolkit: Toolkits.merge(Skills.toTools(skills), Toolkits.fromToolkitOrEmpty(loop.toolkit)),
92
+ toolkit: Toolkits.merge(Skills.toTools(skills), own),
93
93
  history: current,
94
94
  maxSteps: loop.maxSteps ?? 8
95
95
  })
96
96
  }
97
97
  }
98
98
 
99
- // A run lives in memory, so there's nothing to run again after a crash: `ToolStarted` isn't kept. A run parks no
100
- // call (it has no user to ask), so there's no `DecisionRequested` either.
99
+ // A run lives in memory, so there's nothing to run again after a crash: `ToolStarted` isn't kept.
101
100
  const record = Effect.fnUntraced(function* (event: AssistantLoop.Event) {
102
- if (Predicate.isTagged(event, "ToolStarted") || Predicate.isTagged(event, "DecisionRequested")) return
101
+ if (Predicate.isTagged(event, "ToolStarted")) return
103
102
 
104
103
  if (Predicate.isTagged(event, "ToolResult")) return yield* add([toolMessage(event)])
105
104
 
@@ -272,9 +271,7 @@ export const makeRunner = Effect.fnUntraced(function* (options: {
272
271
  chatId: chat.chatId
273
272
  }).pipe(
274
273
  Effect.provideContext(Context.merge(options.model, services)),
275
- // Spawned from a turn, a run would inherit the turn's decisions: it can't park a call or run an approved one.
276
- Effect.provideService(Decisions.TurnDecisions, Option.none()),
277
- // Nor its own tools: a run can't message the user.
274
+ // Spawned from a turn, a run would inherit the turn's own tools: a run can't message the user.
278
275
  Effect.provideService(Loop.TurnTools, Option.none()),
279
276
  Effect.matchCause({
280
277
  onSuccess: (answer): Outcome => ({ status: "answered", report: answer }),
@@ -1,11 +1,9 @@
1
1
  import * as AiError from "effect/ai/AiError"
2
- import type * as Prompt from "effect/ai/Prompt"
3
2
  import type * as Tool from "effect/ai/Tool"
4
3
  import type * as Toolkit from "effect/ai/Toolkit"
5
4
  import type * as Context from "effect/Context"
6
5
  import * as Effect from "effect/Effect"
7
6
  import * as Option from "effect/Option"
8
- import * as Predicate from "effect/Predicate"
9
7
  import * as Schema from "effect/Schema"
10
8
  import * as Stream from "effect/Stream"
11
9
  import type * as AssistantEvent from "../AssistantEvent.ts"
@@ -25,17 +23,13 @@ export interface CallResult {
25
23
  export interface Tools<out R = never> {
26
24
  readonly definitions: ReadonlyArray<Tool.Any>
27
25
  readonly run: (call: AssistantEvent.ToolCallPart) => Effect.Effect<CallResult, never, R>
28
- // Unset: no call waits for the user's decision.
29
- readonly needsDecision?:
30
- | ((call: AssistantEvent.ToolCallPart, history: ReadonlyArray<Prompt.Message>) => Effect.Effect<boolean, never, R>)
31
- | undefined
32
26
  }
33
27
 
34
28
  /** @internal */
35
- export type ToolServices<T extends Record<string, Tool.Any>> =
36
- | Exclude<Tool.HandlerServices<T[keyof T]>, AssistantToolCall>
37
- // Already in the handlers' services; stated for the loop, which decodes params to decide on a call.
38
- | T[keyof T]["parametersSchema"]["DecodingServices"]
29
+ export type ToolServices<T extends Record<string, Tool.Any>> = Exclude<
30
+ Tool.HandlerServices<T[keyof T]>,
31
+ AssistantToolCall
32
+ >
39
33
 
40
34
  const encodeFailure = Schema.encodeSync(ToolFailure.ToolFailure)
41
35
 
@@ -66,36 +60,15 @@ const hasTool = <T extends Record<string, Tool.Any>>(
66
60
  name: PropertyKey
67
61
  ): name is keyof T => Object.hasOwn(toolkit.tools, name)
68
62
 
69
- // With noUncheckedIndexedAccess, an index doesn't narrow on `hasTool`.
70
- const toolNamed = (toolkit: { readonly tools: Record<string, Tool.Any> }, name: string): Tool.Any | undefined =>
71
- Object.hasOwn(toolkit.tools, name) ? toolkit.tools[name] : undefined
72
-
73
- const decodeParams = (tool: Tool.Any, params: Schema.Json) => Schema.decodeUnknownEffect(tool.parametersSchema)(params)
74
-
75
- // Params that don't fit the tool don't park: the call runs, and fails with why.
63
+ // Approval would park a call until the user decides, which this library doesn't do: the model calls a tool with
64
+ // final values, and a user's own commitment goes through the app's API from a block.
76
65
  /** @internal */
77
- export const needsDecision = Effect.fnUntraced(function* <T extends Record<string, Tool.Any>>(
78
- toolkit: { readonly tools: T },
79
- call: AssistantEvent.ToolCallPart,
80
- history: ReadonlyArray<Prompt.Message>
81
- ): Effect.fn.Return<boolean, never, ToolServices<T>> {
82
- const tool = toolNamed(toolkit, call.name)
83
-
84
- if (tool === undefined) return false
66
+ export const needingApproval = (definitions: ReadonlyArray<Tool.Any>): ReadonlyArray<string> =>
67
+ definitions.flatMap((tool) => (tool.needsApproval === undefined || tool.needsApproval === false ? [] : [tool.name]))
85
68
 
86
- const needs = tool.needsApproval
87
-
88
- if (needs === undefined || Predicate.isBoolean(needs)) return needs === true
89
-
90
- return yield* Effect.gen(function* () {
91
- const decided = needs(yield* decodeParams(tool, call.params), {
92
- toolCallId: call.id,
93
- messages: history
94
- })
95
-
96
- return Effect.isEffect(decided) ? yield* decided : decided
97
- }).pipe(Effect.orElseSucceed(() => false))
98
- })
69
+ /** @internal */
70
+ export const needsApprovalMessage = (names: ReadonlyArray<string>): string =>
71
+ `${names.join(", ")} ${names.length === 1 ? "sets" : "set"} needsApproval, and effect-assistant doesn't run tools that wait for approval. Remove needsApproval: let the model call the tool with final values, or show a block whose button calls your app's API, which does the work and submits an app event that wakes the chat.`
99
72
 
100
73
  // The toolkit decodes the params with the tool's schema before its handler sees them, and a call whose params don't
101
74
  // fit fails with a validation error the model reads: the model's JSON is what `handle` takes.
@@ -144,8 +117,7 @@ export const fromWithHandler = <T extends Record<string, Tool.Any>>(
144
117
  toolkit: Toolkit.WithHandler<T>
145
118
  ): Tools<ToolServices<T>> => ({
146
119
  definitions: Object.values(toolkit.tools),
147
- run: (call) => runNative(toolkit, call),
148
- needsDecision: (call, history) => needsDecision(toolkit, call, history)
120
+ run: (call) => runNative(toolkit, call)
149
121
  })
150
122
 
151
123
  /** @internal */
@@ -155,8 +127,7 @@ export const fromToolkit = <T extends Record<string, Tool.Any>>(
155
127
  definitions: Object.values(toolkit.tools),
156
128
  run: Effect.fnUntraced(function* (call) {
157
129
  return yield* runNative(yield* toolkit, call)
158
- }),
159
- needsDecision: (call, history) => needsDecision(toolkit, call, history)
130
+ })
160
131
  })
161
132
 
162
133
  // The handlers use services of the loop they run in, so they're made for each call, with its context.
@@ -171,8 +142,7 @@ export const withContext = <T extends Record<string, Tool.Any>, R>(
171
142
  const resolved = yield* Effect.provideContext(toolkit, yield* toolkit.toHandlers(handlers(context)))
172
143
 
173
144
  return yield* runNative(resolved, call)
174
- }),
175
- needsDecision: (call, history) => needsDecision(toolkit, call, history)
145
+ })
176
146
  })
177
147
 
178
148
  /** @internal */
@@ -192,26 +162,15 @@ export const merge = <A, B>(self: Tools<A>, that: Tools<B>): Tools<A | B> => {
192
162
 
193
163
  return {
194
164
  definitions: [...self.definitions, ...that.definitions],
195
- run: (call) => (inSelf(call) ? self.run(call) : that.run(call)),
196
- needsDecision: (call, history) =>
197
- inSelf(call) ? needsDecisionOf(self, call, history) : needsDecisionOf(that, call, history)
165
+ run: (call) => (inSelf(call) ? self.run(call) : that.run(call))
198
166
  }
199
167
  }
200
168
 
201
- /** @internal */
202
- export const needsDecisionOf = <R>(
203
- tools: Tools<R>,
204
- call: AssistantEvent.ToolCallPart,
205
- history: ReadonlyArray<Prompt.Message>
206
- ): Effect.Effect<boolean, never, R> =>
207
- tools.needsDecision === undefined ? Effect.succeed(false) : tools.needsDecision(call, history)
208
-
209
169
  // A turn's own services, for a loop the app writes.
210
170
  /** @internal */
211
171
  export const provide = <R>(tools: Tools<R>, context: Context.Context<R>): Tools => ({
212
172
  definitions: tools.definitions,
213
- run: (call) => Effect.provideContext(tools.run(call), context),
214
- needsDecision: (call, history) => Effect.provideContext(needsDecisionOf(tools, call, history), context)
173
+ run: (call) => Effect.provideContext(tools.run(call), context)
215
174
  })
216
175
 
217
176
  /** @internal */
@@ -225,7 +184,5 @@ export const duplicateNames = (tools: Tools<unknown>): ReadonlyArray<string> =>
225
184
  /** @internal */
226
185
  export const runOnly = <R>(tools: Tools<R>, names: ReadonlyArray<string>, message: string): Tools<R> => ({
227
186
  definitions: tools.definitions,
228
- run: (call) => (names.includes(call.name) ? tools.run(call) : Effect.succeed(failed(message, "NotNow"))),
229
- needsDecision: (call, history) =>
230
- names.includes(call.name) ? needsDecisionOf(tools, call, history) : Effect.succeed(false)
187
+ run: (call) => (names.includes(call.name) ? tools.run(call) : Effect.succeed(failed(message, "NotNow")))
231
188
  })
@@ -6,9 +6,8 @@ import * as Predicate from "effect/Predicate"
6
6
  import * as Schema from "effect/Schema"
7
7
  import * as AssistantEvent from "../AssistantEvent.ts"
8
8
  import { AssistantToolCall } from "../AssistantToolCall.ts"
9
- import * as BlockKit from "../BlockKit.ts"
9
+ import type * as BlockKit from "../BlockKit.ts"
10
10
  import { CurrentChat } from "../CurrentChat.ts"
11
- import * as Decision from "../Decision.ts"
12
11
  import * as ToolFailure from "../ToolFailure.ts"
13
12
  import * as Toolkits from "./toolkit.ts"
14
13
  import * as Uuid from "./uuid.ts"
@@ -77,45 +76,6 @@ const plainTools = Effect.gen(function* () {
77
76
  return yield* Effect.provideContext(PlainTools, yield* plainHandlers)
78
77
  })
79
78
 
80
- const decodeDecisionProps = Schema.decodeUnknownOption(Decision.Props)
81
-
82
- const checkDecisions = Effect.fnUntraced(function* (ids: ReadonlyArray<string>) {
83
- const states = Decision.fromEntries(yield* CurrentChat.use((chat) => chat.entries))
84
- const notWaiting = ids.filter((id) => states.get(id)?.outcome !== undefined || !states.has(id))
85
-
86
- if (notWaiting.length > 0) {
87
- return yield* ToolFailure.fail(
88
- `Not waiting for the user's decision: ${notWaiting.join(", ")}. Use the ids of decisions that wait for one.`,
89
- { reason: "NotWaitingForDecision" }
90
- )
91
- }
92
- })
93
-
94
- // A decision block closes when the user decides on it, not because the model says so: the order it asks about would
95
- // otherwise never be approved.
96
- const checkCloses = Effect.fnUntraced(function* (ids: ReadonlyArray<string>) {
97
- const entries = yield* CurrentChat.use((chat) => chat.entries)
98
- const states = BlockKit.fromEntries(entries)
99
-
100
- const waiting = ids.filter((id) =>
101
- entries.some(
102
- ({ event }) =>
103
- AssistantEvent.isLibraryEvent(event) &&
104
- Predicate.isTagged(event, "AssistantMessage") &&
105
- event.id === id &&
106
- event.block?.kind === Decision.kind &&
107
- states.get(id)?.open === true
108
- )
109
- )
110
-
111
- if (waiting.length > 0) {
112
- return yield* ToolFailure.fail(
113
- `${waiting.join(", ")} asks for a decision that still waits for the user: only their answer in the block closes it. Send the message again without ${waiting.length === 1 ? "that id" : "those ids"} in closes.`,
114
- { reason: "DecisionWaiting" }
115
- )
116
- }
117
- })
118
-
119
79
  const blockTools = Effect.fnUntraced(function* (kit: BlockKit.BlockKit) {
120
80
  const catalog = kit.blocks.map((block) => `- ${block.kind}: ${block.description}`).join("\n")
121
81
  const kinds = kit.blocks.map((block) => block.kind)
@@ -137,7 +97,7 @@ ${catalog}`,
137
97
  ),
138
98
  closes: Schema.optional(Schema.Array(Schema.String)).annotate({
139
99
  description:
140
- "Ids of your earlier messages whose block the user just answered by typing instead of using it. Those blocks close. Leave it out otherwise: a block stays open when the user talks about something else. A decision block never closes this way: only the user's answer in it decides."
100
+ "Ids of your earlier messages whose block the user just answered by typing instead of using it. Those blocks close. Leave it out otherwise: a block stays open when the user talks about something else."
141
101
  })
142
102
  }),
143
103
  success: Schema.String,
@@ -160,8 +120,6 @@ ${catalog}`,
160
120
  const id = yield* messageId
161
121
  const closed = closes === undefined || closes.length === 0 ? {} : { closes }
162
122
 
163
- if (closes !== undefined && closes.length > 0) yield* checkCloses(closes)
164
-
165
123
  if (block === undefined) {
166
124
  yield* append(AssistantEvent.AssistantMessage.make({ id, text, ...closed }))
167
125
 
@@ -174,15 +132,6 @@ ${catalog}`,
174
132
  return yield* ToolFailure.fail(`Those props don't fit a ${block.kind} block.`)
175
133
  }
176
134
 
177
- if (block.kind === Decision.kind) {
178
- yield* checkDecisions(
179
- Option.match(decodeDecisionProps(block.props), {
180
- onNone: () => [],
181
- onSome: (decoded) => decoded.decisionIds
182
- })
183
- )
184
- }
185
-
186
135
  yield* append(
187
136
  AssistantEvent.AssistantMessage.make({
188
137
  id,