effect-assistant 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -2
- package/dist/AppEvent.d.ts +111 -0
- package/dist/AppEvent.d.ts.map +1 -0
- package/dist/AppEvent.js +71 -0
- package/dist/AppEvent.js.map +1 -0
- package/dist/Assistant.d.ts +403 -0
- package/dist/Assistant.d.ts.map +1 -0
- package/dist/Assistant.js +126 -0
- package/dist/Assistant.js.map +1 -0
- package/dist/AssistantActivity.d.ts +85 -0
- package/dist/AssistantActivity.d.ts.map +1 -0
- package/dist/AssistantActivity.js +105 -0
- package/dist/AssistantActivity.js.map +1 -0
- package/dist/AssistantEngine.d.ts +116 -0
- package/dist/AssistantEngine.d.ts.map +1 -0
- package/dist/AssistantEngine.js +255 -0
- package/dist/AssistantEngine.js.map +1 -0
- package/dist/AssistantEvent.d.ts +614 -0
- package/dist/AssistantEvent.d.ts.map +1 -0
- package/dist/AssistantEvent.js +523 -0
- package/dist/AssistantEvent.js.map +1 -0
- package/dist/AssistantLoop.d.ts +171 -0
- package/dist/AssistantLoop.d.ts.map +1 -0
- package/dist/AssistantLoop.js +108 -0
- package/dist/AssistantLoop.js.map +1 -0
- package/dist/AssistantToolCall.d.ts +32 -0
- package/dist/AssistantToolCall.d.ts.map +1 -0
- package/dist/AssistantToolCall.js +37 -0
- package/dist/AssistantToolCall.js.map +1 -0
- package/dist/Block.d.ts +69 -0
- package/dist/Block.d.ts.map +1 -0
- package/dist/Block.js +39 -0
- package/dist/Block.js.map +1 -0
- package/dist/BlockKit.d.ts +184 -0
- package/dist/BlockKit.d.ts.map +1 -0
- package/dist/BlockKit.js +151 -0
- package/dist/BlockKit.js.map +1 -0
- package/dist/ChatId.d.ts +31 -0
- package/dist/ChatId.d.ts.map +1 -0
- package/dist/ChatId.js +36 -0
- package/dist/ChatId.js.map +1 -0
- package/dist/ChatLog.d.ts +109 -0
- package/dist/ChatLog.d.ts.map +1 -0
- package/dist/ChatLog.js +177 -0
- package/dist/ChatLog.js.map +1 -0
- package/dist/ChatView.d.ts +459 -0
- package/dist/ChatView.d.ts.map +1 -0
- package/dist/ChatView.js +496 -0
- package/dist/ChatView.js.map +1 -0
- package/dist/Chats.d.ts +110 -0
- package/dist/Chats.d.ts.map +1 -0
- package/dist/Chats.js +98 -0
- package/dist/Chats.js.map +1 -0
- package/dist/Compaction.d.ts +105 -0
- package/dist/Compaction.d.ts.map +1 -0
- package/dist/Compaction.js +133 -0
- package/dist/Compaction.js.map +1 -0
- package/dist/CurrentChat.d.ts +40 -0
- package/dist/CurrentChat.d.ts.map +1 -0
- package/dist/CurrentChat.js +33 -0
- package/dist/CurrentChat.js.map +1 -0
- package/dist/Decision.d.ts +145 -0
- package/dist/Decision.d.ts.map +1 -0
- package/dist/Decision.js +178 -0
- package/dist/Decision.js.map +1 -0
- package/dist/DecisionId.d.ts +37 -0
- package/dist/DecisionId.d.ts.map +1 -0
- package/dist/DecisionId.js +44 -0
- package/dist/DecisionId.js.map +1 -0
- package/dist/InputId.d.ts +38 -0
- package/dist/InputId.d.ts.map +1 -0
- package/dist/InputId.js +43 -0
- package/dist/InputId.js.map +1 -0
- package/dist/Instructions.d.ts +56 -0
- package/dist/Instructions.d.ts.map +1 -0
- package/dist/Instructions.js +50 -0
- package/dist/Instructions.js.map +1 -0
- package/dist/Memory.d.ts +190 -0
- package/dist/Memory.d.ts.map +1 -0
- package/dist/Memory.js +216 -0
- package/dist/Memory.js.map +1 -0
- package/dist/RunId.d.ts +41 -0
- package/dist/RunId.d.ts.map +1 -0
- package/dist/RunId.js +46 -0
- package/dist/RunId.js.map +1 -0
- package/dist/Skill.d.ts +72 -0
- package/dist/Skill.d.ts.map +1 -0
- package/dist/Skill.js +58 -0
- package/dist/Skill.js.map +1 -0
- package/dist/Specialist.d.ts +267 -0
- package/dist/Specialist.d.ts.map +1 -0
- package/dist/Specialist.js +119 -0
- package/dist/Specialist.js.map +1 -0
- package/dist/ToolDiscovery.d.ts +108 -0
- package/dist/ToolDiscovery.d.ts.map +1 -0
- package/dist/ToolDiscovery.js +139 -0
- package/dist/ToolDiscovery.js.map +1 -0
- package/dist/ToolFailure.d.ts +66 -0
- package/dist/ToolFailure.d.ts.map +1 -0
- package/dist/ToolFailure.js +79 -0
- package/dist/ToolFailure.js.map +1 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +26 -0
- package/dist/internal/chat.js +202 -0
- package/dist/internal/chat.js.map +1 -0
- package/dist/internal/decisions.js +66 -0
- package/dist/internal/decisions.js.map +1 -0
- package/dist/internal/events.d.ts +46 -0
- package/dist/internal/events.d.ts.map +1 -0
- package/dist/internal/events.js +65 -0
- package/dist/internal/events.js.map +1 -0
- package/dist/internal/history.js +185 -0
- package/dist/internal/history.js.map +1 -0
- package/dist/internal/instructions.js +11 -0
- package/dist/internal/instructions.js.map +1 -0
- package/dist/internal/loop.d.ts +73 -0
- package/dist/internal/loop.d.ts.map +1 -0
- package/dist/internal/loop.js +166 -0
- package/dist/internal/loop.js.map +1 -0
- package/dist/internal/schema.js +14 -0
- package/dist/internal/schema.js.map +1 -0
- package/dist/internal/skills.js +28 -0
- package/dist/internal/skills.js.map +1 -0
- package/dist/internal/specialists.js +273 -0
- package/dist/internal/specialists.js.map +1 -0
- package/dist/internal/telemetry.js +85 -0
- package/dist/internal/telemetry.js.map +1 -0
- package/dist/internal/toolkit.d.ts +76 -0
- package/dist/internal/toolkit.d.ts.map +1 -0
- package/dist/internal/toolkit.js +129 -0
- package/dist/internal/toolkit.js.map +1 -0
- package/dist/internal/tools.js +132 -0
- package/dist/internal/tools.js.map +1 -0
- package/dist/internal/turns.d.ts +64 -0
- package/dist/internal/turns.d.ts.map +1 -0
- package/dist/internal/turns.js +221 -0
- package/dist/internal/turns.js.map +1 -0
- package/dist/internal/uuid.js +45 -0
- package/dist/internal/uuid.js.map +1 -0
- package/dist/internal/writer.d.ts +12 -0
- package/dist/internal/writer.d.ts.map +1 -0
- package/package.json +51 -3
- package/src/AppEvent.ts +134 -0
- package/src/Assistant.ts +589 -0
- package/src/AssistantActivity.ts +150 -0
- package/src/AssistantEngine.ts +343 -0
- package/src/AssistantEvent.ts +635 -0
- package/src/AssistantLoop.ts +207 -0
- package/src/AssistantToolCall.ts +40 -0
- package/src/Block.ts +84 -0
- package/src/BlockKit.ts +311 -0
- package/src/ChatId.ts +36 -0
- package/src/ChatLog.ts +247 -0
- package/src/ChatView.ts +658 -0
- package/src/Chats.ts +122 -0
- package/src/Compaction.ts +224 -0
- package/src/CurrentChat.ts +47 -0
- package/src/Decision.ts +241 -0
- package/src/DecisionId.ts +46 -0
- package/src/InputId.ts +43 -0
- package/src/Instructions.ts +72 -0
- package/src/Memory.ts +276 -0
- package/src/RunId.ts +46 -0
- package/src/Skill.ts +91 -0
- package/src/Specialist.ts +355 -0
- package/src/ToolDiscovery.ts +187 -0
- package/src/ToolFailure.ts +74 -0
- package/src/index.ts +130 -0
- package/src/internal/chat.ts +372 -0
- package/src/internal/decisions.ts +131 -0
- package/src/internal/events.ts +100 -0
- package/src/internal/history.ts +366 -0
- package/src/internal/instructions.ts +9 -0
- package/src/internal/loop.ts +344 -0
- package/src/internal/schema.ts +25 -0
- package/src/internal/skills.ts +37 -0
- package/src/internal/specialists.ts +414 -0
- package/src/internal/telemetry.ts +125 -0
- package/src/internal/toolkit.ts +231 -0
- package/src/internal/tools.ts +210 -0
- package/src/internal/turns.ts +429 -0
- package/src/internal/uuid.ts +50 -0
- package/src/internal/writer.ts +12 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* effect-assistant: a colleague assistant on Effect.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.1.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @since 0.1.0
|
|
9
|
+
*/
|
|
10
|
+
export * as AppEvent from "./AppEvent.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @since 0.1.0
|
|
14
|
+
*/
|
|
15
|
+
export * as Assistant from "./Assistant.ts"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @since 0.1.0
|
|
19
|
+
*/
|
|
20
|
+
export * as AssistantActivity from "./AssistantActivity.ts"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @since 0.1.0
|
|
24
|
+
*/
|
|
25
|
+
export * as AssistantEngine from "./AssistantEngine.ts"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @since 0.1.0
|
|
29
|
+
*/
|
|
30
|
+
export * as AssistantEvent from "./AssistantEvent.ts"
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @since 0.1.0
|
|
34
|
+
*/
|
|
35
|
+
export * as AssistantLoop from "./AssistantLoop.ts"
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @since 0.1.0
|
|
39
|
+
*/
|
|
40
|
+
export * as AssistantToolCall from "./AssistantToolCall.ts"
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @since 0.1.0
|
|
44
|
+
*/
|
|
45
|
+
export * as Block from "./Block.ts"
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @since 0.1.0
|
|
49
|
+
*/
|
|
50
|
+
export * as BlockKit from "./BlockKit.ts"
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @since 0.1.0
|
|
54
|
+
*/
|
|
55
|
+
export * as ChatId from "./ChatId.ts"
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @since 0.1.0
|
|
59
|
+
*/
|
|
60
|
+
export * as ChatLog from "./ChatLog.ts"
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @since 0.1.0
|
|
64
|
+
*/
|
|
65
|
+
export * as Chats from "./Chats.ts"
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @since 0.1.0
|
|
69
|
+
*/
|
|
70
|
+
export * as ChatView from "./ChatView.ts"
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @since 0.1.0
|
|
74
|
+
*/
|
|
75
|
+
export * as Compaction from "./Compaction.ts"
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @since 0.1.0
|
|
79
|
+
*/
|
|
80
|
+
export * as CurrentChat from "./CurrentChat.ts"
|
|
81
|
+
|
|
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
|
+
/**
|
|
93
|
+
* @since 0.1.0
|
|
94
|
+
*/
|
|
95
|
+
export * as InputId from "./InputId.ts"
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @since 0.1.0
|
|
99
|
+
*/
|
|
100
|
+
export * as Instructions from "./Instructions.ts"
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @since 0.1.0
|
|
104
|
+
*/
|
|
105
|
+
export * as Memory from "./Memory.ts"
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @since 0.1.0
|
|
109
|
+
*/
|
|
110
|
+
export * as RunId from "./RunId.ts"
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @since 0.1.0
|
|
114
|
+
*/
|
|
115
|
+
export * as Skill from "./Skill.ts"
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @since 0.1.0
|
|
119
|
+
*/
|
|
120
|
+
export * as Specialist from "./Specialist.ts"
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @since 0.1.0
|
|
124
|
+
*/
|
|
125
|
+
export * as ToolDiscovery from "./ToolDiscovery.ts"
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @since 0.1.0
|
|
129
|
+
*/
|
|
130
|
+
export * as ToolFailure from "./ToolFailure.ts"
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import * as Cause from "effect/Cause"
|
|
2
|
+
import * as Context from "effect/Context"
|
|
3
|
+
import * as Deferred from "effect/Deferred"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import * as Exit from "effect/Exit"
|
|
6
|
+
import * as Fiber from "effect/Fiber"
|
|
7
|
+
import * as FiberSet from "effect/FiberSet"
|
|
8
|
+
import * as Option from "effect/Option"
|
|
9
|
+
import * as Predicate from "effect/Predicate"
|
|
10
|
+
import * as PubSub from "effect/PubSub"
|
|
11
|
+
import * as Ref from "effect/Ref"
|
|
12
|
+
import * as Scope from "effect/Scope"
|
|
13
|
+
import * as Semaphore from "effect/Semaphore"
|
|
14
|
+
import * as Stream from "effect/Stream"
|
|
15
|
+
import * as SynchronizedRef from "effect/SynchronizedRef"
|
|
16
|
+
import * as AssistantEvent from "../AssistantEvent.ts"
|
|
17
|
+
import type { ChatId } from "../ChatId.ts"
|
|
18
|
+
import { ChatLog } from "../ChatLog.ts"
|
|
19
|
+
import { CurrentChat } from "../CurrentChat.ts"
|
|
20
|
+
import type { InputId } from "../InputId.ts"
|
|
21
|
+
import * as Chats from "../Chats.ts"
|
|
22
|
+
import * as SpecialistService from "../Specialist.ts"
|
|
23
|
+
import * as Decisions from "./decisions.ts"
|
|
24
|
+
import * as AppEvents from "./events.ts"
|
|
25
|
+
import * as History from "./history.ts"
|
|
26
|
+
import * as Specialists from "./specialists.ts"
|
|
27
|
+
import * as Turns from "./turns.ts"
|
|
28
|
+
import type { Writer } from "./writer.ts"
|
|
29
|
+
|
|
30
|
+
const idOf = (event: { readonly _tag: string }): string | undefined =>
|
|
31
|
+
AssistantEvent.isLibraryEvent(event)
|
|
32
|
+
? AssistantEvent.isInput(event)
|
|
33
|
+
? event.id
|
|
34
|
+
: undefined
|
|
35
|
+
: Predicate.hasProperty(event, "id") && Predicate.isString(event.id)
|
|
36
|
+
? event.id
|
|
37
|
+
: undefined
|
|
38
|
+
|
|
39
|
+
const makeChannelPubSub = Effect.acquireRelease(PubSub.unbounded<AssistantEvent.Entry>(), PubSub.shutdown)
|
|
40
|
+
|
|
41
|
+
const makeHolding = Effect.fnUntraced(function* (keepAlive: (enabled: boolean) => Effect.Effect<void>) {
|
|
42
|
+
const holds = yield* SynchronizedRef.make(0)
|
|
43
|
+
|
|
44
|
+
const acquire = SynchronizedRef.updateEffect(
|
|
45
|
+
holds,
|
|
46
|
+
Effect.fnUntraced(function* (n: number) {
|
|
47
|
+
if (n !== 0) return n + 1
|
|
48
|
+
|
|
49
|
+
yield* keepAlive(true)
|
|
50
|
+
|
|
51
|
+
return 1
|
|
52
|
+
})
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const release = SynchronizedRef.updateEffect(
|
|
56
|
+
holds,
|
|
57
|
+
Effect.fnUntraced(function* (n: number) {
|
|
58
|
+
if (n !== 1) return n - 1
|
|
59
|
+
|
|
60
|
+
yield* keepAlive(false)
|
|
61
|
+
|
|
62
|
+
return 0
|
|
63
|
+
})
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return (effect: Effect.Effect<void>) =>
|
|
67
|
+
Effect.acquireUseRelease(
|
|
68
|
+
acquire,
|
|
69
|
+
() => effect,
|
|
70
|
+
// An interrupted effect (a shutdown) keeps its hold, so the chat starts again and picks up where it was cut off.
|
|
71
|
+
(_, exit) => (Exit.hasInterrupts(exit) ? Effect.void : release)
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/** @internal */
|
|
76
|
+
export interface Chat {
|
|
77
|
+
readonly submit: (input: AssistantEvent.Input | AssistantEvent.StoredAppEvent) => Effect.Effect<void>
|
|
78
|
+
readonly submitInBackground: (input: AssistantEvent.Input | AssistantEvent.StoredAppEvent) => Effect.Effect<void>
|
|
79
|
+
// For inputs no turn has read, like a report stored during a shutdown.
|
|
80
|
+
readonly resume: Effect.Effect<void>
|
|
81
|
+
readonly stream: (after: number) => Stream.Stream<AssistantEvent.Entry>
|
|
82
|
+
// Stops what works in the chat and ends its subscribers' streams; its log stays.
|
|
83
|
+
readonly delete: Effect.Effect<void>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const isChatDeleted = (entry: AssistantEvent.Entry) => Predicate.isTagged(entry.event, "ChatDeleted")
|
|
87
|
+
|
|
88
|
+
const deletedChat = (entries: ReadonlyArray<AssistantEvent.Entry>): Chat => ({
|
|
89
|
+
submit: () => Effect.void,
|
|
90
|
+
submitInBackground: () => Effect.void,
|
|
91
|
+
resume: Effect.void,
|
|
92
|
+
stream: (after) => Stream.fromIterable(entries.filter((entry) => entry.seq > after)),
|
|
93
|
+
delete: Effect.void
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const refused = (input: { readonly _tag: string }, why: string) =>
|
|
97
|
+
Effect.logDebug(`assistant chat ${why}: ${input._tag} not stored`)
|
|
98
|
+
|
|
99
|
+
/** @internal */
|
|
100
|
+
export const make = Effect.fnUntraced(function* (options: {
|
|
101
|
+
readonly registration: Turns.Registration
|
|
102
|
+
readonly chatId: ChatId
|
|
103
|
+
readonly keepAlive: (enabled: boolean) => Effect.Effect<void>
|
|
104
|
+
}) {
|
|
105
|
+
const { registration, chatId } = options
|
|
106
|
+
const { app } = registration.definition
|
|
107
|
+
const { wakes } = app
|
|
108
|
+
const storage = yield* ChatLog
|
|
109
|
+
const chats = yield* Chats.Chats
|
|
110
|
+
const initial = yield* storage.read(chatId)
|
|
111
|
+
|
|
112
|
+
if (initial.some(isChatDeleted)) return deletedChat(initial)
|
|
113
|
+
|
|
114
|
+
const channel = yield* makeChannelPubSub
|
|
115
|
+
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
|
+
const turnsLock = yield* Semaphore.make(1)
|
|
119
|
+
const holding = yield* makeHolding(options.keepAlive)
|
|
120
|
+
const deleted = yield* Deferred.make<undefined>()
|
|
121
|
+
// So deleting the chat stops all its work: its turns, runs, and the builds' resources.
|
|
122
|
+
const working = yield* Scope.fork(yield* Effect.scope)
|
|
123
|
+
const inWorking = Effect.provideService(Scope.Scope, working)
|
|
124
|
+
const background = yield* inWorking(FiberSet.make())
|
|
125
|
+
const requests = yield* inWorking(FiberSet.make())
|
|
126
|
+
|
|
127
|
+
const isDeleted = Deferred.isDone(deleted)
|
|
128
|
+
|
|
129
|
+
// The chat is its log's only writer, so it reads the log once and turns read memory.
|
|
130
|
+
const entries = yield* Ref.make(initial)
|
|
131
|
+
|
|
132
|
+
// Every writer goes through here, one at a time, so each append is numbered after the last. After a delete it
|
|
133
|
+
// stores nothing.
|
|
134
|
+
const write = Effect.fnUntraced(function* (
|
|
135
|
+
events: ReadonlyArray<AssistantEvent.AssistantEvent | AssistantEvent.StoredAppEvent>
|
|
136
|
+
) {
|
|
137
|
+
const unknown = events.find((event) => !AssistantEvent.isLibraryEvent(event) && !AppEvents.isRegistered(app, event))
|
|
138
|
+
|
|
139
|
+
if (unknown !== undefined) {
|
|
140
|
+
return yield* Effect.die(`${unknown._tag} isn't one of the ${registration.definition.name} assistant's events.`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (yield* isDeleted) return []
|
|
144
|
+
|
|
145
|
+
return yield* stored(events)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Uninterruptible: a stored append must reach memory and the subscribers too, or a turn misses it and replies twice.
|
|
149
|
+
const stored = Effect.fnUntraced(function* (
|
|
150
|
+
events: ReadonlyArray<AssistantEvent.AssistantEvent | AssistantEvent.StoredAppEvent>
|
|
151
|
+
) {
|
|
152
|
+
const stored = yield* storage.append(chatId, events)
|
|
153
|
+
|
|
154
|
+
yield* Ref.update(entries, (all) => [...all, ...stored])
|
|
155
|
+
yield* PubSub.publishAll(channel, stored)
|
|
156
|
+
|
|
157
|
+
return stored
|
|
158
|
+
}, Effect.uninterruptible)
|
|
159
|
+
|
|
160
|
+
const chat: Writer = {
|
|
161
|
+
chatId,
|
|
162
|
+
entries: Ref.get(entries),
|
|
163
|
+
append: (events) => Semaphore.withPermit(writes)(write(events))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const toStored = (event: {
|
|
167
|
+
readonly _tag: string
|
|
168
|
+
}): Effect.Effect<AssistantEvent.AssistantEvent | AssistantEvent.StoredAppEvent> =>
|
|
169
|
+
AssistantEvent.isLibraryEvent(event)
|
|
170
|
+
? Effect.succeed(event)
|
|
171
|
+
: AppEvents.isRegistered(app, event)
|
|
172
|
+
? AppEvents.encode(app, event)
|
|
173
|
+
: Effect.die(`${event._tag} isn't one of the ${registration.definition.name} assistant's events.`)
|
|
174
|
+
|
|
175
|
+
// What handlers, tools, and runs see: the assistant's own events decoded, and encoded when appended.
|
|
176
|
+
const handle: Turns.TypedChat = {
|
|
177
|
+
chatId,
|
|
178
|
+
entries: Effect.map(chat.entries, (all) => AppEvents.decodeEntries(app, all)),
|
|
179
|
+
append: (...events) => Effect.asVoid(Effect.flatMap(Effect.forEach(events, toStored), chat.append))
|
|
180
|
+
}
|
|
181
|
+
|
|
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
|
+
// A chat the app deleted is deleted here too, which stops what works in it: a turn that checks stops there.
|
|
190
|
+
const checkAvailable: Effect.Effect<boolean> = chats.get(chatId).pipe(
|
|
191
|
+
Effect.as(true),
|
|
192
|
+
Effect.catchTag("GetChatError", (unavailable) =>
|
|
193
|
+
unavailable.reason === "Deleted"
|
|
194
|
+
? Effect.as(
|
|
195
|
+
Effect.suspend(() => remove),
|
|
196
|
+
false
|
|
197
|
+
)
|
|
198
|
+
: Effect.succeed(false)
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
// Under `storing`: an answer's decisions are resolved before the next input is stored.
|
|
203
|
+
const store = Effect.fnUntraced(function* (input: AssistantEvent.Input | AssistantEvent.StoredAppEvent) {
|
|
204
|
+
if (yield* isDeleted) return yield* refused(input, "is deleted")
|
|
205
|
+
|
|
206
|
+
if (!(yield* checkAvailable)) return yield* refused(input, "is unavailable")
|
|
207
|
+
|
|
208
|
+
// 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)(
|
|
210
|
+
Effect.gen(function* () {
|
|
211
|
+
if (yield* isDeleted) {
|
|
212
|
+
yield* refused(input, "is deleted")
|
|
213
|
+
|
|
214
|
+
return Option.none()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const stored = yield* Ref.get(entries)
|
|
218
|
+
const id = idOf(input)
|
|
219
|
+
|
|
220
|
+
if (id !== undefined && stored.some(({ event }) => idOf(event) === id)) return Option.none()
|
|
221
|
+
|
|
222
|
+
const verdict = Decisions.verdict(stored, input)
|
|
223
|
+
|
|
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)
|
|
233
|
+
})
|
|
234
|
+
)
|
|
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
|
+
)
|
|
247
|
+
|
|
248
|
+
// The handlers' builds run in the chat's working scope: what they set up lives until the chat closes or is deleted.
|
|
249
|
+
const handler = yield* inWorking(Effect.provideService(registration.open(handle), CurrentChat, handle))
|
|
250
|
+
|
|
251
|
+
const specialists = yield* Effect.forEach(
|
|
252
|
+
registration.specialists,
|
|
253
|
+
Effect.fnUntraced(function* ({ specialist, built }) {
|
|
254
|
+
const handler = yield* inWorking(Effect.provideService(built.open, CurrentChat, handle))
|
|
255
|
+
|
|
256
|
+
return { specialist, handler }
|
|
257
|
+
})
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
const inBackground = (effect: Effect.Effect<void>) => FiberSet.run(background, holding(effect)).pipe(Effect.asVoid)
|
|
261
|
+
|
|
262
|
+
// A request's turns run in the working scope too, so a delete stops them; the request ends quietly then.
|
|
263
|
+
const inRequest = Effect.fnUntraced(function* (effect: Effect.Effect<void>) {
|
|
264
|
+
const fiber = yield* FiberSet.run(requests, effect)
|
|
265
|
+
const exit = yield* Fiber.await(fiber).pipe(Effect.onInterrupt(() => Fiber.interrupt(fiber)))
|
|
266
|
+
|
|
267
|
+
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) {
|
|
268
|
+
return yield* Effect.failCause(exit.cause)
|
|
269
|
+
}
|
|
270
|
+
}, Effect.orDie)
|
|
271
|
+
|
|
272
|
+
const drain: Effect.Effect<void> = Semaphore.withPermit(turnsLock)(
|
|
273
|
+
Effect.gen(function* () {
|
|
274
|
+
while (!(yield* isDeleted) && History.needsTurn(yield* chat.entries, wakes)) {
|
|
275
|
+
yield* Turns.run({ registration, handler, chat, services, checkOpen: Effect.asVoid(checkAvailable) })
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
const runner = yield* inWorking(
|
|
281
|
+
Specialists.makeRunner({
|
|
282
|
+
chat,
|
|
283
|
+
handle,
|
|
284
|
+
specialists,
|
|
285
|
+
model: registration.model,
|
|
286
|
+
holding,
|
|
287
|
+
report: Effect.fnUntraced(function* (finished) {
|
|
288
|
+
yield* store(finished)
|
|
289
|
+
yield* inBackground(drain)
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
const services = Context.make(CurrentChat, handle).pipe(
|
|
295
|
+
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
|
+
Context.merge(registration.model)
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
const remove = Effect.gen(function* () {
|
|
310
|
+
// Under the write lock: an append in flight is stored before the `ChatDeleted`; none comes after.
|
|
311
|
+
const first = yield* Semaphore.withPermit(writes)(
|
|
312
|
+
Effect.gen(function* () {
|
|
313
|
+
if (yield* isDeleted) return false
|
|
314
|
+
|
|
315
|
+
yield* stored([AssistantEvent.ChatDeleted.make({})])
|
|
316
|
+
|
|
317
|
+
return yield* Deferred.succeed(deleted, undefined)
|
|
318
|
+
})
|
|
319
|
+
).pipe(Effect.uninterruptible)
|
|
320
|
+
|
|
321
|
+
if (!first) return
|
|
322
|
+
|
|
323
|
+
// Its own fiber: a tool that deletes its own chat is in the working scope, and closing it interrupts that tool.
|
|
324
|
+
// Interrupted work keeps its hold (see `makeHolding`), so the chat lets go of it here.
|
|
325
|
+
const closing = yield* Effect.forkDetach(
|
|
326
|
+
Effect.gen(function* () {
|
|
327
|
+
yield* Scope.close(working, Exit.void)
|
|
328
|
+
yield* options.keepAlive(false)
|
|
329
|
+
})
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
yield* Fiber.await(closing)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// A chat the app deleted while it was closed is deleted now, so its subscribers hear it.
|
|
336
|
+
yield* checkAvailable
|
|
337
|
+
|
|
338
|
+
const chatApi: Chat = {
|
|
339
|
+
submit: Effect.fnUntraced(function* (input) {
|
|
340
|
+
yield* store(input)
|
|
341
|
+
yield* inRequest(drain)
|
|
342
|
+
}),
|
|
343
|
+
submitInBackground: Effect.fnUntraced(function* (input) {
|
|
344
|
+
yield* store(input)
|
|
345
|
+
yield* inBackground(drain)
|
|
346
|
+
}),
|
|
347
|
+
resume: Effect.gen(function* () {
|
|
348
|
+
if (History.needsTurn(yield* chat.entries, wakes)) yield* inBackground(drain)
|
|
349
|
+
}),
|
|
350
|
+
stream: (after) =>
|
|
351
|
+
Stream.unwrap(
|
|
352
|
+
Effect.gen(function* () {
|
|
353
|
+
// Subscribes before it reads, so nothing appended in between is missed.
|
|
354
|
+
const subscription = yield* PubSub.subscribe(channel)
|
|
355
|
+
const all = yield* chat.entries
|
|
356
|
+
const stored = all.filter((entry) => entry.seq > after)
|
|
357
|
+
const last = stored.at(-1)?.seq ?? after
|
|
358
|
+
|
|
359
|
+
// Deleted: what the reader hasn't seen, then the end. Otherwise the stream ends with the `ChatDeleted`.
|
|
360
|
+
if (all.some(isChatDeleted)) return Stream.fromIterable(stored)
|
|
361
|
+
|
|
362
|
+
return Stream.concat(
|
|
363
|
+
Stream.fromIterable(stored),
|
|
364
|
+
Stream.fromSubscription(subscription).pipe(Stream.filter((entry) => entry.seq > last))
|
|
365
|
+
).pipe(Stream.takeUntil(isChatDeleted))
|
|
366
|
+
})
|
|
367
|
+
),
|
|
368
|
+
delete: remove
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return chatApi
|
|
372
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as Response from "effect/ai/Response"
|
|
2
|
+
import * as Context from "effect/Context"
|
|
3
|
+
import * as Data from "effect/Data"
|
|
4
|
+
import type * as Effect from "effect/Effect"
|
|
5
|
+
import * as Option from "effect/Option"
|
|
6
|
+
import * as Predicate from "effect/Predicate"
|
|
7
|
+
import * as Schema from "effect/Schema"
|
|
8
|
+
import * as AssistantEvent from "../AssistantEvent.ts"
|
|
9
|
+
import * as BlockKit from "../BlockKit.ts"
|
|
10
|
+
import * as Decision from "../Decision.ts"
|
|
11
|
+
import type { InputId } from "../InputId.ts"
|
|
12
|
+
|
|
13
|
+
// Only a turn's loop parks calls and runs approved ones. A turn's specialists inherit its context, so their runs set
|
|
14
|
+
// it back to none.
|
|
15
|
+
/** @internal */
|
|
16
|
+
export const TurnDecisions = Context.Reference<
|
|
17
|
+
Option.Option<{
|
|
18
|
+
readonly approved: Effect.Effect<ReadonlyArray<AssistantEvent.ToolCallPart>>
|
|
19
|
+
}>
|
|
20
|
+
>("effect-assistant/TurnDecisions", { defaultValue: Option.none })
|
|
21
|
+
|
|
22
|
+
/** @internal */
|
|
23
|
+
export interface Decided {
|
|
24
|
+
readonly request: AssistantEvent.DecisionRequested
|
|
25
|
+
readonly outcome: AssistantEvent.DecisionOutcome
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @internal */
|
|
29
|
+
export type Verdict = Data.TaggedEnum<{
|
|
30
|
+
Store: Record<never, never>
|
|
31
|
+
Refuse: { readonly reason: string }
|
|
32
|
+
Decide: { readonly decided: ReadonlyArray<Decided>; readonly answer: InputId }
|
|
33
|
+
}>
|
|
34
|
+
|
|
35
|
+
const Verdicts = Data.taggedEnum<Verdict>()
|
|
36
|
+
|
|
37
|
+
const store = Verdicts.Store()
|
|
38
|
+
|
|
39
|
+
const refuse = (reason: string) => Verdicts.Refuse({ reason })
|
|
40
|
+
|
|
41
|
+
const decodeProps = Schema.decodeUnknownOption(Decision.Props)
|
|
42
|
+
|
|
43
|
+
const decodeAnswer = Schema.decodeUnknownOption(Decision.Answer)
|
|
44
|
+
|
|
45
|
+
const messageOf = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, id: string) =>
|
|
46
|
+
entries
|
|
47
|
+
.map(({ event }) => event)
|
|
48
|
+
.find(
|
|
49
|
+
(event): event is AssistantEvent.AssistantMessage =>
|
|
50
|
+
AssistantEvent.isLibraryEvent(event) && Predicate.isTagged(event, "AssistantMessage") && event.id === id
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const answerVerdict = (entries: ReadonlyArray<AssistantEvent.AnyEntry>, answer: AssistantEvent.BlockAnswer) => {
|
|
54
|
+
const message = messageOf(entries, answer.messageId)
|
|
55
|
+
const onDecisionBlock = message?.block?.kind === Decision.kind
|
|
56
|
+
|
|
57
|
+
if (!onDecisionBlock && answer.kind !== Decision.kind) return store
|
|
58
|
+
|
|
59
|
+
if (message?.block === undefined || !onDecisionBlock || answer.kind !== Decision.kind) {
|
|
60
|
+
return refuse(`${answer.messageId} has no decision block.`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (BlockKit.fromEntries(entries).get(message.id)?.open !== true)
|
|
64
|
+
return refuse(`The block on ${message.id} is closed.`)
|
|
65
|
+
|
|
66
|
+
const props = decodeProps(message.block.props)
|
|
67
|
+
const value = decodeAnswer(answer.value)
|
|
68
|
+
|
|
69
|
+
if (Option.isNone(props) || Option.isNone(value)) return refuse(`That isn't an answer to the block on ${message.id}.`)
|
|
70
|
+
|
|
71
|
+
const asked = props.value.decisionIds
|
|
72
|
+
const given = Object.keys(value.value.decisions)
|
|
73
|
+
|
|
74
|
+
if (given.length !== asked.length || !given.every((id) => asked.some((askedId) => askedId === id))) {
|
|
75
|
+
return refuse(`An answer decides exactly the decisions its block asks for: ${asked.join(", ")}.`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const states = Decision.fromEntries(entries)
|
|
79
|
+
const decided: Array<Decided> = []
|
|
80
|
+
|
|
81
|
+
for (const id of asked) {
|
|
82
|
+
const state = states.get(id)
|
|
83
|
+
const outcome = value.value.decisions[id]
|
|
84
|
+
|
|
85
|
+
if (state === undefined || outcome === undefined) return refuse(`No decision ${id}.`)
|
|
86
|
+
|
|
87
|
+
if (state.outcome !== undefined) return refuse(`${id} is already decided.`)
|
|
88
|
+
|
|
89
|
+
decided.push({ request: state.request, outcome })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return Verdicts.Decide({ decided, answer: answer.id })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @internal */
|
|
96
|
+
export const verdict = (
|
|
97
|
+
entries: ReadonlyArray<AssistantEvent.AnyEntry>,
|
|
98
|
+
input: AssistantEvent.Input | AssistantEvent.StoredAppEvent
|
|
99
|
+
): Verdict =>
|
|
100
|
+
AssistantEvent.isLibraryEvent(input) && Predicate.isTagged(input, "BlockAnswer")
|
|
101
|
+
? answerVerdict(entries, input)
|
|
102
|
+
: store
|
|
103
|
+
|
|
104
|
+
// A restart can cut the chat off between storing an answer and resolving what it decided.
|
|
105
|
+
/** @internal */
|
|
106
|
+
export const unresolved = (
|
|
107
|
+
entries: ReadonlyArray<AssistantEvent.AnyEntry>
|
|
108
|
+
): ReadonlyArray<Decided & { readonly answer: InputId }> =>
|
|
109
|
+
[...Decision.fromEntries(entries).values()].flatMap((state) =>
|
|
110
|
+
state.outcome !== undefined && state.answer !== undefined && !state.resolved
|
|
111
|
+
? [{ request: state.request, outcome: state.outcome, answer: state.answer }]
|
|
112
|
+
: []
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// They run with the ids and params the model gave them, before the model's next step.
|
|
116
|
+
/** @internal */
|
|
117
|
+
export const approvedCalls = (
|
|
118
|
+
entries: ReadonlyArray<AssistantEvent.AnyEntry>
|
|
119
|
+
): ReadonlyArray<AssistantEvent.ToolCallPart> =>
|
|
120
|
+
[...Decision.fromEntries(entries).values()].flatMap(({ request, outcome, resolved, result }) =>
|
|
121
|
+
resolved && outcome === "approved" && result === undefined
|
|
122
|
+
? [
|
|
123
|
+
Response.makePart("tool-call", {
|
|
124
|
+
id: request.origin.callId,
|
|
125
|
+
name: request.origin.tool,
|
|
126
|
+
params: request.origin.params,
|
|
127
|
+
providerExecuted: false
|
|
128
|
+
})
|
|
129
|
+
]
|
|
130
|
+
: []
|
|
131
|
+
)
|