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
@@ -94,8 +94,8 @@ 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
- * `execute_tool` doesn't run a tool that needs the user's decision; keep such tools in the loop's
98
- * toolkit directly.
97
+ * A catalog tool with Effect's `needsApproval` set fails the layer when it is built, as it would
98
+ * fail a loop: the library doesn't run calls that wait for approval.
99
99
  *
100
100
  * **Example** (Finding rarely used tools when needed)
101
101
  *
@@ -146,6 +146,9 @@ export const layer = <T extends Record<string, Tool.Any>>(catalog: Toolkit.Toolk
146
146
  Effect.gen(function* () {
147
147
  const resolved = yield* catalog
148
148
  const definitions = Object.values(resolved.tools)
149
+ const needingApproval = Toolkits.needingApproval(definitions)
150
+
151
+ if (needingApproval.length > 0) return yield* Effect.die(Toolkits.needsApprovalMessage(needingApproval))
149
152
 
150
153
  return {
151
154
  search_tools: ({ query }) =>
@@ -167,17 +170,6 @@ export const layer = <T extends Record<string, Tool.Any>>(catalog: Toolkit.Toolk
167
170
  providerExecuted: false
168
171
  })
169
172
 
170
- // A call that needs the user's decision parks only as a call the model made itself,
171
- // where it can ask for it.
172
- if (yield* Toolkits.needsDecision(resolved, call, [])) {
173
- return yield* Effect.fail(
174
- Toolkits.failed(
175
- `${name} needs the user's decision, so it can't run through execute_tool.`,
176
- "NeedsDecision"
177
- ).result
178
- )
179
- }
180
-
181
173
  const { outcome, result } = yield* Toolkits.runNative(resolved, call)
182
174
 
183
175
  return outcome === "failure" ? yield* Effect.fail(result) : result
@@ -15,7 +15,7 @@ import * as Schema from "effect/Schema"
15
15
  *
16
16
  * It holds a message that says what went wrong and, optionally, a `reason` the model can tell
17
17
  * apart, such as `"NotFound"` or `"OverBudget"`. The library's own reasons are PascalCase, like
18
- * `"NeedsDecision"`.
18
+ * `"ToolNotFoundError"`.
19
19
  *
20
20
  * @category errors
21
21
  * @since 0.1.0
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
  */
@@ -79,16 +69,6 @@ export * as Compaction from "./Compaction.ts"
79
69
  */
80
70
  export * as CurrentChat from "./CurrentChat.ts"
81
71
 
82
- /**
83
- * @since 0.1.0
84
- */
85
- export * as Decision from "./Decision.ts"
86
-
87
- /**
88
- * @since 0.1.0
89
- */
90
- export * as DecisionId from "./DecisionId.ts"
91
-
92
72
  /**
93
73
  * @since 0.1.0
94
74
  */
@@ -119,6 +99,11 @@ export * as Skill from "./Skill.ts"
119
99
  */
120
100
  export * as Specialist from "./Specialist.ts"
121
101
 
102
+ /**
103
+ * @since 0.3.0
104
+ */
105
+ export * as Timeline from "./Timeline.ts"
106
+
122
107
  /**
123
108
  * @since 0.1.0
124
109
  */
@@ -5,7 +5,6 @@ import * as Effect from "effect/Effect"
5
5
  import * as Exit from "effect/Exit"
6
6
  import * as Fiber from "effect/Fiber"
7
7
  import * as FiberSet from "effect/FiberSet"
8
- import * as Option from "effect/Option"
9
8
  import * as Predicate from "effect/Predicate"
10
9
  import * as PubSub from "effect/PubSub"
11
10
  import * as Ref from "effect/Ref"
@@ -17,10 +16,8 @@ import * as AssistantEvent from "../AssistantEvent.ts"
17
16
  import type { ChatId } from "../ChatId.ts"
18
17
  import { ChatLog } from "../ChatLog.ts"
19
18
  import { CurrentChat } from "../CurrentChat.ts"
20
- import type { InputId } from "../InputId.ts"
21
19
  import * as Chats from "../Chats.ts"
22
20
  import * as SpecialistService from "../Specialist.ts"
23
- import * as Decisions from "./decisions.ts"
24
21
  import * as AppEvents from "./events.ts"
25
22
  import * as History from "./history.ts"
26
23
  import * as Specialists from "./specialists.ts"
@@ -104,7 +101,6 @@ export const make = Effect.fnUntraced(function* (options: {
104
101
  }) {
105
102
  const { registration, chatId } = options
106
103
  const { app } = registration.definition
107
- const { wakes } = app
108
104
  const storage = yield* ChatLog
109
105
  const chats = yield* Chats.Chats
110
106
  const initial = yield* storage.read(chatId)
@@ -113,8 +109,6 @@ export const make = Effect.fnUntraced(function* (options: {
113
109
 
114
110
  const channel = yield* makeChannelPubSub
115
111
  const writes = yield* Semaphore.make(1)
116
- // Inputs are stored one at a time with the decisions they make: an answer checks the decisions the last one made.
117
- const storing = yield* Semaphore.make(1)
118
112
  const turnsLock = yield* Semaphore.make(1)
119
113
  const holding = yield* makeHolding(options.keepAlive)
120
114
  const deleted = yield* Deferred.make<undefined>()
@@ -179,13 +173,6 @@ export const make = Effect.fnUntraced(function* (options: {
179
173
  append: (...events) => Effect.asVoid(Effect.flatMap(Effect.forEach(events, toStored), chat.append))
180
174
  }
181
175
 
182
- // An approved call runs at the turn's next step.
183
- const resolve = Effect.fnUntraced(function* (decided: ReadonlyArray<Decisions.Decided>, answer: InputId) {
184
- yield* chat.append(
185
- decided.map(({ request, outcome }) => AssistantEvent.DecisionResolved.make({ id: request.id, outcome, answer }))
186
- )
187
- })
188
-
189
176
  // A chat the app deleted is deleted here too, which stops what works in it: a turn that checks stops there.
190
177
  const checkAvailable: Effect.Effect<boolean> = chats.get(chatId).pipe(
191
178
  Effect.as(true),
@@ -199,51 +186,25 @@ export const make = Effect.fnUntraced(function* (options: {
199
186
  )
200
187
  )
201
188
 
202
- // Under `storing`: an answer's decisions are resolved before the next input is stored.
203
189
  const store = Effect.fnUntraced(function* (input: AssistantEvent.Input | AssistantEvent.StoredAppEvent) {
204
190
  if (yield* isDeleted) return yield* refused(input, "is deleted")
205
191
 
206
192
  if (!(yield* checkAvailable)) return yield* refused(input, "is unavailable")
207
193
 
208
194
  // Checked under the write lock: a retry that arrives while the first submit is storing is stored once.
209
- const verdict = yield* Semaphore.withPermit(writes)(
195
+ yield* Semaphore.withPermit(writes)(
210
196
  Effect.gen(function* () {
211
- if (yield* isDeleted) {
212
- yield* refused(input, "is deleted")
197
+ if (yield* isDeleted) return yield* refused(input, "is deleted")
213
198
 
214
- return Option.none()
215
- }
216
-
217
- const stored = yield* Ref.get(entries)
218
199
  const id = idOf(input)
219
200
 
220
- if (id !== undefined && stored.some(({ event }) => idOf(event) === id)) return Option.none()
221
-
222
- const verdict = Decisions.verdict(stored, input)
201
+ if (id !== undefined && (yield* Ref.get(entries)).some(({ event }) => idOf(event) === id)) return
223
202
 
224
- if (Predicate.isTagged(verdict, "Refuse")) {
225
- yield* Effect.logWarning(`assistant chat refused ${input._tag}: ${verdict.reason}`)
226
-
227
- return Option.none()
228
- }
229
-
230
- yield* write([input])
231
-
232
- return Option.some(verdict)
203
+ // Stored as an input: it came in from outside a turn, so it wakes one, also after a restart.
204
+ yield* write([AssistantEvent.isLibraryEvent(input) ? input : AppEvents.markInput(input)])
233
205
  })
234
206
  )
235
-
236
- if (Option.isSome(verdict) && Predicate.isTagged(verdict.value, "Decide")) {
237
- yield* resolve(verdict.value.decided, verdict.value.answer)
238
- }
239
- }, Semaphore.withPermit(storing))
240
-
241
- // A restart between storing an answer and resolving it: resolve it now, before anything reads the chat.
242
- yield* Effect.forEach(
243
- Decisions.unresolved(yield* Ref.get(entries)),
244
- (decided) => resolve([decided], decided.answer),
245
- { discard: true }
246
- )
207
+ })
247
208
 
248
209
  // The handlers' builds run in the chat's working scope: what they set up lives until the chat closes or is deleted.
249
210
  const handler = yield* inWorking(Effect.provideService(registration.open(handle), CurrentChat, handle))
@@ -271,7 +232,7 @@ export const make = Effect.fnUntraced(function* (options: {
271
232
 
272
233
  const drain: Effect.Effect<void> = Semaphore.withPermit(turnsLock)(
273
234
  Effect.gen(function* () {
274
- while (!(yield* isDeleted) && History.needsTurn(yield* chat.entries, wakes)) {
235
+ while (!(yield* isDeleted) && History.needsTurn(yield* chat.entries)) {
275
236
  yield* Turns.run({ registration, handler, chat, services, checkOpen: Effect.asVoid(checkAvailable) })
276
237
  }
277
238
  })
@@ -293,16 +254,6 @@ export const make = Effect.fnUntraced(function* (options: {
293
254
 
294
255
  const services = Context.make(CurrentChat, handle).pipe(
295
256
  Context.add(SpecialistService.Runner, runner),
296
- Context.add(
297
- Decisions.TurnDecisions,
298
- Option.some({
299
- approved: Effect.gen(function* () {
300
- const all = yield* chat.entries
301
-
302
- return Decisions.approvedCalls(all)
303
- })
304
- })
305
- ),
306
257
  Context.merge(registration.model)
307
258
  )
308
259
 
@@ -345,7 +296,7 @@ export const make = Effect.fnUntraced(function* (options: {
345
296
  yield* inBackground(drain)
346
297
  }),
347
298
  resume: Effect.gen(function* () {
348
- if (History.needsTurn(yield* chat.entries, wakes)) yield* inBackground(drain)
299
+ if (History.needsTurn(yield* chat.entries)) yield* inBackground(drain)
349
300
  }),
350
301
  stream: (after) =>
351
302
  Stream.unwrap(
@@ -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 type * as AppEvent from "../AppEvent.ts"
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 { toJsonText } from "./schema.ts"
9
+ import { inputKey } from "./input.ts"
8
10
 
9
11
  /** @internal */
10
12
  export interface AppEvents {
11
- readonly byTag: ReadonlyMap<string, Registered>
12
- readonly wakes: ReadonlySet<string>
13
+ readonly byTag: ReadonlyMap<string, AssistantEvent.AppEventSchema>
13
14
  }
14
15
 
15
- interface Registered {
16
- readonly schema: AppEvent.EventSchema
17
- readonly render: AppEvent.Render<{ readonly _tag: string }> | undefined
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<AppEvent.Any>): AppEvents => {
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, wakes: new Set(events.flatMap((event) => (event.wakes ? [event.tag] : []))) }
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 registered = app.byTag.get(event._tag)
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 (registered === undefined) return yield* Effect.die(`${event._tag} isn't one of this assistant's events.`)
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(registered.schema))(event)
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
- const registered = app.byTag.get(event._tag)
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
- }
@@ -5,7 +5,6 @@ import * as Option from "effect/Option"
5
5
  import * as Predicate from "effect/Predicate"
6
6
  import * as Schema from "effect/Schema"
7
7
  import * as AssistantEvent from "../AssistantEvent.ts"
8
- import * as Decision from "../Decision.ts"
9
8
  import { toJson, toJsonText } from "./schema.ts"
10
9
  import * as Uuid from "./uuid.ts"
11
10
 
@@ -108,73 +107,23 @@ const finishedText = (
108
107
  const answerText = (answer: AssistantEvent.BlockAnswer) =>
109
108
  `<answer message="${answer.messageId}" kind="${answer.kind}">\nThe user answered with the block on your message ${answer.messageId}.\n${toJsonText(answer.value)}\n</answer>`
110
109
 
111
- /** @internal */
112
- export const parkedResult = (
113
- request: AssistantEvent.DecisionRequested,
114
- origin: AssistantEvent.CallOrigin
115
- ): Prompt.ToolMessage =>
116
- Prompt.toolMessage({
117
- content: [
118
- Prompt.toolResultPart({
119
- id: origin.callId,
120
- name: origin.tool,
121
- isFailure: false,
122
- result: {
123
- status: "waiting_for_decision",
124
- decisionId: request.id,
125
- next: "Not run: it runs only if the user approves it. Send them a message with a decision block for this decisionId that says what they approve (the call's key facts). Their answer runs it, or declines it."
126
- },
127
- providerExecuted: false
128
- })
129
- ]
130
- })
131
-
132
- const whatDecided = (request: AssistantEvent.DecisionRequested, outcome: AssistantEvent.DecisionOutcome) =>
133
- outcome === "approved"
134
- ? `approved: ${request.origin.tool} runs now, and its result follows.`
135
- : `declined: ${request.origin.tool} didn't run, and won't.`
136
-
137
- const decodeDecisionAnswer = Schema.decodeUnknownOption(Decision.Answer)
138
-
139
- const decisionAnswerText = (
140
- answer: AssistantEvent.BlockAnswer,
141
- requests: ReadonlyMap<string, AssistantEvent.DecisionRequested>
142
- ) =>
143
- Option.match(decodeDecisionAnswer(answer.value), {
144
- onNone: () => answerText(answer),
145
- onSome: ({ decisions }) => {
146
- const lines = Object.entries(decisions).map(([id, outcome]) => {
147
- const request = requests.get(id)
148
-
149
- return `- ${id}: ${request === undefined ? outcome : whatDecided(request, outcome)}`
150
- })
151
-
152
- return `<answer message="${answer.messageId}" kind="${answer.kind}">\nThe user decided with the block on your message ${answer.messageId}:\n${lines.join("\n")}\n</answer>`
153
- }
154
- })
155
-
156
110
  const resultText = (result: AssistantEvent.ToolResult) =>
157
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>`
158
112
 
159
- const approvedResultText = (
160
- request: AssistantEvent.DecisionRequested,
161
- tool: string,
162
- result: AssistantEvent.ToolResult
163
- ) =>
164
- `<decision id="${request.id}" tool="${tool}" outcome="approved">\nThe user approved it, and ${tool} ran: ${result.outcome}.\n${toJsonText(result.result)}\n</decision>`
113
+ const isEntry = (item: AssistantEvent.AnyEntry | Prompt.Message): item is AssistantEvent.AnyEntry =>
114
+ Predicate.hasProperty(item, "seq")
165
115
 
166
- // In the order the model saw the entries, not the order they were stored.
116
+ // In the order the model saw the entries, not the order they were stored. A message a history view put in stands
117
+ // where it is, right after the entry before it, and waits like an input for a call's result or for the model's next
118
+ // read; the app's own events reach the model only as such messages.
167
119
  /** @internal */
168
120
  export const fromEntries = (
169
- entries: ReadonlyArray<AssistantEvent.AnyEntry>,
170
- readApp: (event: { readonly _tag: string }) => string | undefined
121
+ items: ReadonlyArray<AssistantEvent.AnyEntry | Prompt.Message>
171
122
  ): ReadonlyArray<Prompt.Message> => {
123
+ const entries = items.filter(isEntry)
172
124
  const history: Array<Prompt.Message> = []
173
125
  const held: Array<Prompt.Message> = []
174
126
  const open = new Set<string>()
175
- const requests = new Map<string, AssistantEvent.DecisionRequested>()
176
- // Parked calls by call id: their result came with the parking, so a later one reads as the decision's.
177
- const parked = new Map<string, AssistantEvent.DecisionRequested>()
178
127
  const called = new Set<string>()
179
128
 
180
129
  const flushIfClosed = () => {
@@ -189,31 +138,35 @@ export const fromEntries = (
189
138
  : undefined
190
139
  }
191
140
 
192
- const input = (index: number, item: Prompt.Message) => {
141
+ // `index` is the last entry at or before the item, and `seq` where the item stands.
142
+ const input = (index: number, seq: number, item: Prompt.Message) => {
193
143
  const seen = nextSeen(index)
194
144
 
195
- if (open.size > 0 || (seen !== undefined && seen < entries[index]!.seq)) held.push(item)
145
+ if (open.size > 0 || (seen !== undefined && seen < seq)) held.push(item)
196
146
  else history.push(item)
197
147
  }
198
148
 
199
- entries.forEach(({ event }, index) => {
200
- if (!AssistantEvent.isLibraryEvent(event)) {
201
- const text = readApp(event)
149
+ let index = -1
202
150
 
203
- if (text !== undefined) input(index, userText(text))
151
+ for (const item of items) {
152
+ if (!isEntry(item)) {
153
+ input(index, (entries[index]?.seq ?? 0) + 0.5, item)
204
154
 
205
- return
155
+ continue
206
156
  }
207
157
 
158
+ index += 1
159
+
160
+ const { event, seq } = item
161
+ const at = index
162
+
163
+ if (!AssistantEvent.isLibraryEvent(event)) continue
164
+
208
165
  Match.value(event).pipe(
209
166
  Match.tagsExhaustive({
210
167
  UserMessage: (message) =>
211
- input(index, userText(AssistantEvent.textWithAttachments(message.text, message.attachments))),
212
- BlockAnswer: (answer) =>
213
- input(
214
- index,
215
- userText(answer.kind === Decision.kind ? decisionAnswerText(answer, requests) : answerText(answer))
216
- ),
168
+ input(at, seq, userText(AssistantEvent.textWithAttachments(message.text, message.attachments))),
169
+ BlockAnswer: (answer) => input(at, seq, userText(answerText(answer))),
217
170
  ModelOutput: (output) => {
218
171
  history.push(...Prompt.fromResponseParts(output.parts).content)
219
172
 
@@ -227,46 +180,34 @@ export const fromEntries = (
227
180
  flushIfClosed()
228
181
  },
229
182
  ToolResult: (result) => {
230
- const request = parked.get(result.callId)
231
-
232
- if (request !== undefined) return input(index, userText(approvedResultText(request, result.tool, result)))
233
-
234
183
  // A view that starts after the call (since a summary, say) keeps its result as text: a tool result needs its
235
- // call right before it. An approved call's result can come long after the call was parked.
236
- if (!called.has(result.callId)) return input(index, userText(resultText(result)))
184
+ // call right before it.
185
+ if (!called.has(result.callId)) return input(at, seq, userText(resultText(result)))
237
186
 
238
187
  history.push(toolMessage(result))
239
188
  open.delete(result.callId)
240
189
  flushIfClosed()
241
190
  },
242
- DecisionRequested: (request) => {
243
- requests.set(request.id, request)
244
- parked.set(request.origin.callId, request)
245
- history.push(parkedResult(request, request.origin))
246
- open.delete(request.origin.callId)
247
- flushIfClosed()
248
- },
249
- DecisionResolved: () => undefined,
250
191
  AssistantMessage: () => undefined,
251
192
  ToolStarted: () => undefined,
252
193
  TurnStarted: () => undefined,
253
194
  TurnCompleted: () => undefined,
254
195
  TurnFailed: () => undefined,
255
- SpecialistFinished: (finished) => input(index, userText(finishedText(entries, index, finished))),
196
+ SpecialistFinished: (finished) => input(at, seq, userText(finishedText(entries, at, finished))),
256
197
  // Every run starts in a call of the model's own (a spawn or a follow-up), whose result says so.
257
198
  SpecialistStarted: () => undefined,
258
199
  BlocksClosed: () => undefined,
259
200
  ChatDeleted: () => undefined
260
201
  })
261
202
  )
262
- })
203
+ }
263
204
 
264
205
  return [...history, ...held]
265
206
  }
266
207
 
267
208
  /** @internal */
268
- export const isInput = (event: { readonly _tag: string }, wakes: ReadonlySet<string>): boolean =>
269
- AssistantEvent.isInput(event) || (!AssistantEvent.isLibraryEvent(event) && wakes.has(event._tag))
209
+ export const isInput = (event: { readonly _tag: string }): boolean =>
210
+ AssistantEvent.isInput(event) || AssistantEvent.isAppInput(event)
270
211
 
271
212
  /** @internal */
272
213
  export const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): number => {
@@ -278,21 +219,18 @@ export const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>):
278
219
  // A run the assistant stopped isn't an input here: it wakes nothing.
279
220
  /** @internal */
280
221
  export const unhandledInputs = (
281
- entries: ReadonlyArray<AssistantEvent.AnyEntry>,
282
- wakes: ReadonlySet<string>
222
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>
283
223
  ): ReadonlyArray<{ readonly _tag: string }> => {
284
224
  const seen = handledThrough(entries)
285
225
 
286
226
  return entries.flatMap(({ event, seq }) =>
287
- seq > seen && isInput(event, wakes) && !(AssistantEvent.isInput(event) && !AssistantEvent.isWaking(event))
288
- ? [event]
289
- : []
227
+ seq > seen && isInput(event) && !(AssistantEvent.isInput(event) && !AssistantEvent.isWaking(event)) ? [event] : []
290
228
  )
291
229
  }
292
230
 
293
231
  /** @internal */
294
- export const needsTurn = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, wakes: ReadonlySet<string>): boolean =>
295
- unhandledInputs(entries, wakes).length > 0
232
+ export const needsTurn = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): boolean =>
233
+ unhandledInputs(entries).length > 0
296
234
 
297
235
  // A failure the user was shown counts as a reply.
298
236
  /** @internal */
@@ -359,8 +297,5 @@ export const owesDelivery = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, th
359
297
  }
360
298
 
361
299
  /** @internal */
362
- export const unreadAfter = (
363
- entries: ReadonlyArray<AssistantEvent.AnyEntry>,
364
- throughSeq: number,
365
- wakes: ReadonlySet<string>
366
- ): boolean => entries.some(({ event, seq }) => seq > throughSeq && isInput(event, wakes))
300
+ export const unreadAfter = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, throughSeq: number): boolean =>
301
+ entries.some(({ event, seq }) => seq > throughSeq && isInput(event))
@@ -0,0 +1,3 @@
1
+ // Marks a stored app event that came in through the assistant's `submit`: an input, which wakes a turn.
2
+ /** @internal */
3
+ export const inputKey = "~effect-assistant/input"