effect-assistant 0.2.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 (89) hide show
  1. package/dist/Assistant.d.ts +74 -28
  2. package/dist/Assistant.d.ts.map +1 -1
  3. package/dist/Assistant.js +11 -4
  4. package/dist/Assistant.js.map +1 -1
  5. package/dist/AssistantActivity.d.ts +10 -16
  6. package/dist/AssistantActivity.d.ts.map +1 -1
  7. package/dist/AssistantActivity.js +11 -4
  8. package/dist/AssistantActivity.js.map +1 -1
  9. package/dist/AssistantEngine.d.ts +39 -1
  10. package/dist/AssistantEngine.d.ts.map +1 -1
  11. package/dist/AssistantEngine.js +81 -28
  12. package/dist/AssistantEngine.js.map +1 -1
  13. package/dist/AssistantEvent.d.ts +50 -5
  14. package/dist/AssistantEvent.d.ts.map +1 -1
  15. package/dist/AssistantEvent.js +28 -7
  16. package/dist/AssistantEvent.js.map +1 -1
  17. package/dist/BlockKit.d.ts +4 -4
  18. package/dist/BlockKit.js +1 -1
  19. package/dist/BlockKit.js.map +1 -1
  20. package/dist/ChatLog.js +1 -1
  21. package/dist/Compaction.d.ts +6 -2
  22. package/dist/Compaction.d.ts.map +1 -1
  23. package/dist/Compaction.js +6 -2
  24. package/dist/Compaction.js.map +1 -1
  25. package/dist/CurrentChat.d.ts +2 -1
  26. package/dist/CurrentChat.d.ts.map +1 -1
  27. package/dist/CurrentChat.js.map +1 -1
  28. package/dist/Instructions.js +1 -1
  29. package/dist/Skill.d.ts +33 -0
  30. package/dist/Skill.d.ts.map +1 -1
  31. package/dist/Skill.js +27 -1
  32. package/dist/Skill.js.map +1 -1
  33. package/dist/Specialist.d.ts +4 -1
  34. package/dist/Specialist.d.ts.map +1 -1
  35. package/dist/Specialist.js.map +1 -1
  36. package/dist/Timeline.d.ts +482 -0
  37. package/dist/Timeline.d.ts.map +1 -0
  38. package/dist/Timeline.js +572 -0
  39. package/dist/Timeline.js.map +1 -0
  40. package/dist/index.d.ts +2 -3
  41. package/dist/index.js +2 -3
  42. package/dist/internal/chat.js +5 -6
  43. package/dist/internal/chat.js.map +1 -1
  44. package/dist/internal/events.d.ts +15 -16
  45. package/dist/internal/events.d.ts.map +1 -1
  46. package/dist/internal/events.js +29 -28
  47. package/dist/internal/events.js.map +1 -1
  48. package/dist/internal/history.js +25 -19
  49. package/dist/internal/history.js.map +1 -1
  50. package/dist/internal/input.js +7 -0
  51. package/dist/internal/input.js.map +1 -0
  52. package/dist/internal/loop.js +1 -1
  53. package/dist/internal/skills.js +6 -1
  54. package/dist/internal/skills.js.map +1 -1
  55. package/dist/internal/specialists.js +5 -4
  56. package/dist/internal/specialists.js.map +1 -1
  57. package/dist/internal/tools.js +1 -1
  58. package/dist/internal/turns.d.ts.map +1 -1
  59. package/dist/internal/turns.js +23 -35
  60. package/dist/internal/turns.js.map +1 -1
  61. package/package.json +1 -1
  62. package/src/Assistant.ts +75 -29
  63. package/src/AssistantActivity.ts +11 -18
  64. package/src/AssistantEngine.ts +110 -43
  65. package/src/AssistantEvent.ts +46 -8
  66. package/src/BlockKit.ts +6 -6
  67. package/src/Compaction.ts +6 -2
  68. package/src/CurrentChat.ts +2 -1
  69. package/src/Skill.ts +34 -1
  70. package/src/Specialist.ts +4 -1
  71. package/src/Timeline.ts +861 -0
  72. package/src/index.ts +5 -10
  73. package/src/internal/chat.ts +4 -4
  74. package/src/internal/events.ts +43 -43
  75. package/src/internal/history.ts +36 -29
  76. package/src/internal/input.ts +3 -0
  77. package/src/internal/skills.ts +11 -0
  78. package/src/internal/specialists.ts +3 -2
  79. package/src/internal/turns.ts +30 -53
  80. package/dist/AppEvent.d.ts +0 -111
  81. package/dist/AppEvent.d.ts.map +0 -1
  82. package/dist/AppEvent.js +0 -71
  83. package/dist/AppEvent.js.map +0 -1
  84. package/dist/ChatView.d.ts +0 -408
  85. package/dist/ChatView.d.ts.map +0 -1
  86. package/dist/ChatView.js +0 -443
  87. package/dist/ChatView.js.map +0 -1
  88. package/src/AppEvent.ts +0 -134
  89. package/src/ChatView.ts +0 -587
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,11 @@ 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
+
112
107
  /**
113
108
  * @since 0.1.0
114
109
  */
@@ -101,7 +101,6 @@ export const make = Effect.fnUntraced(function* (options: {
101
101
  }) {
102
102
  const { registration, chatId } = options
103
103
  const { app } = registration.definition
104
- const { wakes } = app
105
104
  const storage = yield* ChatLog
106
105
  const chats = yield* Chats.Chats
107
106
  const initial = yield* storage.read(chatId)
@@ -201,7 +200,8 @@ export const make = Effect.fnUntraced(function* (options: {
201
200
 
202
201
  if (id !== undefined && (yield* Ref.get(entries)).some(({ event }) => idOf(event) === id)) return
203
202
 
204
- yield* write([input])
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)])
205
205
  })
206
206
  )
207
207
  })
@@ -232,7 +232,7 @@ export const make = Effect.fnUntraced(function* (options: {
232
232
 
233
233
  const drain: Effect.Effect<void> = Semaphore.withPermit(turnsLock)(
234
234
  Effect.gen(function* () {
235
- while (!(yield* isDeleted) && History.needsTurn(yield* chat.entries, wakes)) {
235
+ while (!(yield* isDeleted) && History.needsTurn(yield* chat.entries)) {
236
236
  yield* Turns.run({ registration, handler, chat, services, checkOpen: Effect.asVoid(checkAvailable) })
237
237
  }
238
238
  })
@@ -296,7 +296,7 @@ export const make = Effect.fnUntraced(function* (options: {
296
296
  yield* inBackground(drain)
297
297
  }),
298
298
  resume: Effect.gen(function* () {
299
- if (History.needsTurn(yield* chat.entries, wakes)) yield* inBackground(drain)
299
+ if (History.needsTurn(yield* chat.entries)) yield* inBackground(drain)
300
300
  }),
301
301
  stream: (after) =>
302
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
- }
@@ -110,12 +110,17 @@ 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
- // In the order the model saw the entries, not the order they were stored.
113
+ const isEntry = (item: AssistantEvent.AnyEntry | Prompt.Message): item is AssistantEvent.AnyEntry =>
114
+ Predicate.hasProperty(item, "seq")
115
+
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.
114
119
  /** @internal */
115
120
  export const fromEntries = (
116
- entries: ReadonlyArray<AssistantEvent.AnyEntry>,
117
- readApp: (event: { readonly _tag: string }) => string | undefined
121
+ items: ReadonlyArray<AssistantEvent.AnyEntry | Prompt.Message>
118
122
  ): ReadonlyArray<Prompt.Message> => {
123
+ const entries = items.filter(isEntry)
119
124
  const history: Array<Prompt.Message> = []
120
125
  const held: Array<Prompt.Message> = []
121
126
  const open = new Set<string>()
@@ -133,27 +138,35 @@ export const fromEntries = (
133
138
  : undefined
134
139
  }
135
140
 
136
- 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) => {
137
143
  const seen = nextSeen(index)
138
144
 
139
- 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)
140
146
  else history.push(item)
141
147
  }
142
148
 
143
- entries.forEach(({ event }, index) => {
144
- if (!AssistantEvent.isLibraryEvent(event)) {
145
- const text = readApp(event)
149
+ let index = -1
146
150
 
147
- 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)
148
154
 
149
- return
155
+ continue
150
156
  }
151
157
 
158
+ index += 1
159
+
160
+ const { event, seq } = item
161
+ const at = index
162
+
163
+ if (!AssistantEvent.isLibraryEvent(event)) continue
164
+
152
165
  Match.value(event).pipe(
153
166
  Match.tagsExhaustive({
154
167
  UserMessage: (message) =>
155
- input(index, userText(AssistantEvent.textWithAttachments(message.text, message.attachments))),
156
- BlockAnswer: (answer) => input(index, userText(answerText(answer))),
168
+ input(at, seq, userText(AssistantEvent.textWithAttachments(message.text, message.attachments))),
169
+ BlockAnswer: (answer) => input(at, seq, userText(answerText(answer))),
157
170
  ModelOutput: (output) => {
158
171
  history.push(...Prompt.fromResponseParts(output.parts).content)
159
172
 
@@ -169,7 +182,7 @@ export const fromEntries = (
169
182
  ToolResult: (result) => {
170
183
  // A view that starts after the call (since a summary, say) keeps its result as text: a tool result needs its
171
184
  // call right before it.
172
- if (!called.has(result.callId)) return input(index, userText(resultText(result)))
185
+ if (!called.has(result.callId)) return input(at, seq, userText(resultText(result)))
173
186
 
174
187
  history.push(toolMessage(result))
175
188
  open.delete(result.callId)
@@ -180,21 +193,21 @@ export const fromEntries = (
180
193
  TurnStarted: () => undefined,
181
194
  TurnCompleted: () => undefined,
182
195
  TurnFailed: () => undefined,
183
- SpecialistFinished: (finished) => input(index, userText(finishedText(entries, index, finished))),
196
+ SpecialistFinished: (finished) => input(at, seq, userText(finishedText(entries, at, finished))),
184
197
  // Every run starts in a call of the model's own (a spawn or a follow-up), whose result says so.
185
198
  SpecialistStarted: () => undefined,
186
199
  BlocksClosed: () => undefined,
187
200
  ChatDeleted: () => undefined
188
201
  })
189
202
  )
190
- })
203
+ }
191
204
 
192
205
  return [...history, ...held]
193
206
  }
194
207
 
195
208
  /** @internal */
196
- export const isInput = (event: { readonly _tag: string }, wakes: ReadonlySet<string>): boolean =>
197
- 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)
198
211
 
199
212
  /** @internal */
200
213
  export const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): number => {
@@ -206,21 +219,18 @@ export const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>):
206
219
  // A run the assistant stopped isn't an input here: it wakes nothing.
207
220
  /** @internal */
208
221
  export const unhandledInputs = (
209
- entries: ReadonlyArray<AssistantEvent.AnyEntry>,
210
- wakes: ReadonlySet<string>
222
+ entries: ReadonlyArray<AssistantEvent.AnyEntry>
211
223
  ): ReadonlyArray<{ readonly _tag: string }> => {
212
224
  const seen = handledThrough(entries)
213
225
 
214
226
  return entries.flatMap(({ event, seq }) =>
215
- seq > seen && isInput(event, wakes) && !(AssistantEvent.isInput(event) && !AssistantEvent.isWaking(event))
216
- ? [event]
217
- : []
227
+ seq > seen && isInput(event) && !(AssistantEvent.isInput(event) && !AssistantEvent.isWaking(event)) ? [event] : []
218
228
  )
219
229
  }
220
230
 
221
231
  /** @internal */
222
- export const needsTurn = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, wakes: ReadonlySet<string>): boolean =>
223
- unhandledInputs(entries, wakes).length > 0
232
+ export const needsTurn = (entries: ReadonlyArray<AssistantEvent.AnyEntry>): boolean =>
233
+ unhandledInputs(entries).length > 0
224
234
 
225
235
  // A failure the user was shown counts as a reply.
226
236
  /** @internal */
@@ -287,8 +297,5 @@ export const owesDelivery = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, th
287
297
  }
288
298
 
289
299
  /** @internal */
290
- export const unreadAfter = (
291
- entries: ReadonlyArray<AssistantEvent.AnyEntry>,
292
- throughSeq: number,
293
- wakes: ReadonlySet<string>
294
- ): 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"
@@ -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
@@ -84,11 +84,12 @@ export const runBrief = Effect.fnUntraced(
84
84
  brief: options.brief,
85
85
  history: current,
86
86
  loop: (loop) => {
87
- const skills = loop.skills ?? []
87
+ const own = Toolkits.fromToolkitOrEmpty(loop.toolkit)
88
+ const skills = Skills.available(loop.skills ?? [], own.definitions)
88
89
 
89
90
  return Loop.run({
90
91
  instructions: mapText(loop.instructions, (text) => Skill.withCatalog(text, skills)),
91
- toolkit: Toolkits.merge(Skills.toTools(skills), Toolkits.fromToolkitOrEmpty(loop.toolkit)),
92
+ toolkit: Toolkits.merge(Skills.toTools(skills), own),
92
93
  history: current,
93
94
  maxSteps: loop.maxSteps ?? 8
94
95
  })
@@ -1,6 +1,5 @@
1
1
  import type * as LanguageModel from "effect/ai/LanguageModel"
2
2
  import type * as Tool from "effect/ai/Tool"
3
- import type * as Prompt from "effect/ai/Prompt"
4
3
  import * as Cause from "effect/Cause"
5
4
  import * as Context from "effect/Context"
6
5
  import * as Effect from "effect/Effect"
@@ -8,6 +7,7 @@ import * as Exit from "effect/Exit"
8
7
  import { identity } from "effect/Function"
9
8
  import * as Option from "effect/Option"
10
9
  import * as Predicate from "effect/Predicate"
10
+ import * as Ref from "effect/Ref"
11
11
  import * as Schema from "effect/Schema"
12
12
  import type * as Scope from "effect/Scope"
13
13
  import * as Stream from "effect/Stream"
@@ -127,34 +127,6 @@ const failureMessage = (cause: Cause.Cause<unknown>): string =>
127
127
 
128
128
  const lastSeq = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, otherwise: number) => entries.at(-1)?.seq ?? otherwise
129
129
 
130
- const toHistory = (
131
- viewed: ReadonlyArray<Assistant.Entry<any> | Prompt.Message>,
132
- app: AppEvents.AppEvents
133
- ): ReadonlyArray<Prompt.Message> => {
134
- const out: Array<Prompt.Message> = []
135
- let run: Array<AssistantEvent.AnyEntry> = []
136
-
137
- const flush = () => {
138
- out.push(...History.fromEntries(run, (event) => AppEvents.read(app, event)))
139
- run = []
140
- }
141
-
142
- for (const item of viewed) {
143
- if (isEntry(item)) run.push(item)
144
- else {
145
- flush()
146
- out.push(item)
147
- }
148
- }
149
-
150
- flush()
151
-
152
- return out
153
- }
154
-
155
- const isEntry = (item: Assistant.Entry<any> | Prompt.Message): item is Assistant.Entry<any> =>
156
- Predicate.hasProperty(item, "seq")
157
-
158
130
  // The handler's stream can end between a step's output and its tools, or while they run. An interrupted turn (a
159
131
  // shutdown) leaves its calls open instead: the next turn runs them again.
160
132
  const closeOpenCalls = Effect.fnUntraced(function* (chat: Writer, startSeq: number) {
@@ -164,7 +136,7 @@ const closeOpenCalls = Effect.fnUntraced(function* (chat: Writer, startSeq: numb
164
136
  entries.flatMap(({ event }) => (Predicate.isTagged(event, "ToolStarted") ? [event.callId] : []))
165
137
  )
166
138
 
167
- const open = History.unansweredCalls(History.fromEntries(entries, () => undefined))
139
+ const open = History.unansweredCalls(History.fromEntries(entries))
168
140
 
169
141
  if (open.length === 0) return
170
142
 
@@ -196,13 +168,13 @@ const handledThrough = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, startSe
196
168
  startSeq
197
169
  )
198
170
 
199
- // Fails visibly when the user's latest input got no reply; an input that arrived after what its model read gets the
200
- // next turn, which replies.
201
- const finishTurn = Effect.fnUntraced(function* (chat: Writer, read: number, wakes: ReadonlySet<string>) {
171
+ // A turn that ran the loop fails visibly when the user's latest input got no reply; an input that arrived after what
172
+ // its model read gets the next turn, which replies. A handler that ended without the loop decided that itself.
173
+ const finishTurn = Effect.fnUntraced(function* (chat: Writer, read: number, ranLoop: boolean) {
202
174
  const entries = yield* chat.entries
203
175
 
204
176
  const end =
205
- History.owesReply(entries, read) && !History.unreadAfter(entries, read, wakes)
177
+ ranLoop && History.owesReply(entries, read) && !History.unreadAfter(entries, read)
206
178
  ? AssistantEvent.TurnFailed.make({ throughSeq: read, message: silentTurnMessage })
207
179
  : AssistantEvent.TurnCompleted.make({ throughSeq: read })
208
180
 
@@ -222,11 +194,15 @@ const makeTurnApi = (
222
194
  registration: Registration,
223
195
  chat: Writer,
224
196
  startSeq: number,
225
- checkOpen: Effect.Effect<void>
197
+ checkOpen: Effect.Effect<void>,
198
+ ranLoop: Ref.Ref<boolean>
226
199
  ): Assistant.Turn<any> => {
227
200
  const { definition, builtins } = registration
228
201
  const { app } = definition
229
202
 
203
+ const running = <A, E, R>(stream: Stream.Stream<A, E, R>) =>
204
+ Stream.unwrap(Ref.set(ranLoop, true).pipe(Effect.as(stream)))
205
+
230
206
  // With how far into the chat it goes: the step records it on the model's output.
231
207
  // Read before every model call: a chat the app deleted meanwhile stops here.
232
208
  const history = Effect.fnUntraced(function* (view: Assistant.HistoryView<any> = identity) {
@@ -235,7 +211,7 @@ const makeTurnApi = (
235
211
  const all = yield* chat.entries
236
212
 
237
213
  const read: AssistantLoop.ChatHistory = {
238
- messages: toHistory(view(AppEvents.decodeEntries(app, all)), app),
214
+ messages: History.fromEntries(view(AppEvents.decodeEntries(app, all))),
239
215
  throughSeq: lastSeq(all, startSeq)
240
216
  }
241
217
 
@@ -258,7 +234,7 @@ const makeTurnApi = (
258
234
  const through = handledThrough(all, startSeq)
259
235
 
260
236
  // Something arrived after what the loop's model read: the next turn reads it with the full loop, and replies.
261
- if (History.unreadAfter(all, through, app.wakes)) return Stream.empty
237
+ if (History.unreadAfter(all, through)) return Stream.empty
262
238
 
263
239
  const owed = History.owesReply(all, through)
264
240
  const undelivered = !owed && History.owesDelivery(all, through)
@@ -282,7 +258,7 @@ const makeTurnApi = (
282
258
 
283
259
  return {
284
260
  messages: [
285
- ...toHistory(options.view(AppEvents.decodeEntries(app, entries)), app),
261
+ ...History.fromEntries(options.view(AppEvents.decodeEntries(app, entries))),
286
262
  History.userText(nudge)
287
263
  ],
288
264
  throughSeq: lastSeq(entries, startSeq)
@@ -295,22 +271,22 @@ const makeTurnApi = (
295
271
  )
296
272
 
297
273
  const replyIfOwed = (options: Assistant.ReplyOptions) =>
298
- replyPass({
299
- instructions: instructions(options.instructions),
300
- toolkit: builtins,
301
- view: identity,
302
- nudge: options.nudge
303
- })
274
+ running(
275
+ replyPass({
276
+ instructions: instructions(options.instructions),
277
+ toolkit: builtins,
278
+ view: identity,
279
+ nudge: options.nudge
280
+ })
281
+ )
304
282
 
305
283
  const prepare = <T extends Record<string, Tool.Any>, RS>(options: Assistant.LoopOptions<T, RS, any>) => {
306
- const skills = options.skills ?? []
284
+ const own = Toolkits.fromToolkitOrEmpty(options.toolkit)
285
+ const skills = Skills.available(options.skills ?? [], [...builtins.definitions, ...own.definitions])
307
286
 
308
287
  return {
309
288
  system: mapText(instructions(options.instructions), (text) => Skill.withCatalog(text, skills)),
310
- toolkit: Toolkits.merge(
311
- Toolkits.merge(builtins, Skills.toTools(skills)),
312
- Toolkits.fromToolkitOrEmpty(options.toolkit)
313
- ),
289
+ toolkit: Toolkits.merge(Toolkits.merge(builtins, Skills.toTools(skills)), own),
314
290
  view: options.history ?? identity
315
291
  }
316
292
  }
@@ -334,7 +310,7 @@ const makeTurnApi = (
334
310
  maxSteps: options.maxSteps ?? 16
335
311
  })
336
312
 
337
- return Stream.concat(loop, replyPass({ instructions: system, toolkit, view, nudge: options.replyNudge }))
313
+ return running(Stream.concat(loop, replyPass({ instructions: system, toolkit, view, nudge: options.replyNudge })))
338
314
  }
339
315
  }
340
316
  }
@@ -353,7 +329,7 @@ export const run = Effect.fnUntraced(
353
329
  const { chat } = options
354
330
  const before = yield* chat.entries
355
331
 
356
- yield* Telemetry.agentInput(History.unhandledInputs(before, options.registration.definition.app.wakes))
332
+ yield* Telemetry.agentInput(History.unhandledInputs(before))
357
333
 
358
334
  const assistant = options.registration.definition.name
359
335
  const traceId = yield* Telemetry.currentTraceId
@@ -368,7 +344,8 @@ export const run = Effect.fnUntraced(
368
344
  ])
369
345
 
370
346
  const startSeq = lastSeq(started, 0)
371
- const turn = makeTurnApi(options.registration, chat, startSeq, options.checkOpen)
347
+ const ranLoop = yield* Ref.make(false)
348
+ const turn = makeTurnApi(options.registration, chat, startSeq, options.checkOpen, ranLoop)
372
349
 
373
350
  const record = (event: AssistantLoop.Event) => chat.append([event])
374
351
 
@@ -385,7 +362,7 @@ export const run = Effect.fnUntraced(
385
362
  onSuccess: Effect.fnUntraced(function* () {
386
363
  const all = yield* chat.entries
387
364
 
388
- yield* finishTurn(chat, handledThrough(all, startSeq), options.registration.definition.app.wakes)
365
+ yield* finishTurn(chat, handledThrough(all, startSeq), yield* Ref.get(ranLoop))
389
366
  }),
390
367
  onFailure: (cause) => failTurn(chat, cause)
391
368
  })