effect-assistant 0.8.0 → 0.8.1
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/README.md +240 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,23 +1,248 @@
|
|
|
1
|
+
<p align="left">
|
|
2
|
+
<a href="https://www.npmjs.com/package/effect-assistant"><img alt="npm version" src="https://img.shields.io/npm/v/effect-assistant.svg"></a>
|
|
3
|
+
<a href="https://github.com/dbrxnds/effect-assistant/blob/main/LICENSE"><img alt="MIT license" src="https://img.shields.io/npm/l/effect-assistant.svg"></a>
|
|
4
|
+
<a href="https://effect.website"><img alt="Built on Effect" src="https://img.shields.io/badge/built%20on-Effect%204-black"></a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
> [!WARNING]
|
|
8
|
+
> effect-assistant is under active development. APIs may change between minor versions until 1.0.
|
|
9
|
+
|
|
1
10
|
# effect-assistant
|
|
2
11
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
12
|
+
effect-assistant is a TypeScript library for chat assistants that work like a colleague: you message it, it works
|
|
13
|
+
in the background with your tools, and it messages you back. It is built on [Effect](https://effect.website) and
|
|
14
|
+
`effect/ai`.
|
|
15
|
+
|
|
16
|
+
Each chat is one append-only log of events. Everything else is read from it:
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
{ model's history, user's timeline, what to resume } = f(chat log)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quickstart
|
|
9
23
|
|
|
10
|
-
```
|
|
24
|
+
```sh
|
|
11
25
|
pnpm add effect-assistant effect@^4.0.0 @effect/ai-anthropic@^4.0.0
|
|
12
26
|
```
|
|
13
27
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
28
|
+
```ts
|
|
29
|
+
import * as AnthropicClient from "@effect/ai-anthropic/AnthropicClient"
|
|
30
|
+
import * as AnthropicLanguageModel from "@effect/ai-anthropic/AnthropicLanguageModel"
|
|
31
|
+
import { Assistant, AssistantEngine, AssistantEvent, ChatId, ChatLog } from "effect-assistant"
|
|
32
|
+
import * as Tool from "effect/ai/Tool"
|
|
33
|
+
import * as Toolkit from "effect/ai/Toolkit"
|
|
34
|
+
import * as Config from "effect/Config"
|
|
35
|
+
import * as Console from "effect/Console"
|
|
36
|
+
import * as Effect from "effect/Effect"
|
|
37
|
+
import * as FetchHttpClient from "effect/http/FetchHttpClient"
|
|
38
|
+
import * as Layer from "effect/Layer"
|
|
39
|
+
import * as Predicate from "effect/Predicate"
|
|
40
|
+
import * as Schema from "effect/Schema"
|
|
41
|
+
import * as Stream from "effect/Stream"
|
|
42
|
+
|
|
43
|
+
export const MyAssistant = Assistant.make("my-assistant")
|
|
44
|
+
|
|
45
|
+
const WeatherTools = Toolkit.make(
|
|
46
|
+
Tool.make("get_weather", {
|
|
47
|
+
description: "Current weather for a city.",
|
|
48
|
+
parameters: Schema.Struct({ city: Schema.String }),
|
|
49
|
+
success: Schema.String
|
|
50
|
+
})
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const WeatherToolsLive = WeatherTools.toLayer({
|
|
54
|
+
get_weather: ({ city }) => Effect.succeed(`${city}: sunny, 21°C`)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const MyAssistantLive = MyAssistant.toLayer((turn) =>
|
|
58
|
+
turn.run({ instructions: "You help a small team. Be brief.", toolkit: WeatherTools })
|
|
59
|
+
).pipe(Layer.provide(WeatherToolsLive))
|
|
60
|
+
|
|
61
|
+
const ModelLive = AnthropicLanguageModel.model("claude-sonnet-5-5").pipe(
|
|
62
|
+
Layer.provide(AnthropicClient.layerConfig({ apiKey: Config.Redacted("ANTHROPIC_API_KEY") })),
|
|
63
|
+
Layer.provide(FetchHttpClient.layer)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const AppLive = MyAssistantLive.pipe(
|
|
67
|
+
Layer.provide(ModelLive),
|
|
68
|
+
Layer.provideMerge(AssistantEngine.layerMemory),
|
|
69
|
+
Layer.provideMerge(ChatLog.layerMemory)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const program = Effect.gen(function* () {
|
|
73
|
+
const assistant = yield* MyAssistant
|
|
74
|
+
const chatId = yield* ChatId.make()
|
|
75
|
+
|
|
76
|
+
yield* assistant.submit(chatId, AssistantEvent.UserMessage.make({ text: "Weather in Oslo?" }))
|
|
77
|
+
|
|
78
|
+
yield* assistant.subscribe(chatId).pipe(
|
|
79
|
+
Stream.takeUntil(({ event }) => Predicate.isTagged(event, "TurnCompleted")),
|
|
80
|
+
Stream.runForEach(({ event }) =>
|
|
81
|
+
Predicate.isTagged(event, "AssistantMessage") ? Console.log(event.text) : Effect.void
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
Effect.runPromise(program.pipe(Effect.provide(AppLive)))
|
|
87
|
+
// Oslo is sunny, 21°C.
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### A turn is a stream
|
|
91
|
+
|
|
92
|
+
`submit` appends the input to the chat's log and returns; a turn starts in the background. The handler returns
|
|
93
|
+
the turn as a `Stream` of what the agent loop does, and the engine writes each event to the log before it pulls the
|
|
94
|
+
next, so a turn cut off by a crash picks up where it stopped. Change a turn with ordinary stream code:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
const MyAssistantLive = MyAssistant.toLayer((turn) =>
|
|
98
|
+
turn.run({ instructions, toolkit: WeatherTools }).pipe(
|
|
99
|
+
Stream.concat(Stream.drain(Stream.fromEffect(afterTheReply))) // the user doesn't wait for it
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```text
|
|
105
|
+
submit ──► chat log ──► history ──► turn.run ──► events ──┐
|
|
106
|
+
▲ │
|
|
107
|
+
└───────────────────────────────────────────┘
|
|
108
|
+
chat log ──► Timeline.changes ──► client
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Every model call reads the log
|
|
112
|
+
|
|
113
|
+
The model's history is rebuilt from the log before each call. A message the user sends while a turn runs is read
|
|
114
|
+
at the turn's next step, so follow-ups join the work instead of queueing behind it.
|
|
115
|
+
|
|
116
|
+
### The model speaks through a tool
|
|
117
|
+
|
|
118
|
+
The model's own text never reaches the user. It sends a message with `send_user_message`, sets `wait` on its last
|
|
119
|
+
one, and ends a turn with nothing to say with the `wait` tool. Tool output and working notes stay out of the chat.
|
|
120
|
+
A turn that stops after "On it" without the result gets one more pass to deliver it.
|
|
121
|
+
|
|
122
|
+
### The user sees a timeline
|
|
123
|
+
|
|
124
|
+
The client never reads the log. `Timeline.changes` turns it into the chat as the user sees it (messages, blocks,
|
|
125
|
+
approvals, whether something is working), and the client folds the changes:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const watch = (chatId: ChatId.ChatId) =>
|
|
129
|
+
Stream.unwrap(MyAssistant.useSync((assistant) => assistant.subscribe(chatId).pipe(Timeline.changes(MyAssistant))))
|
|
130
|
+
|
|
131
|
+
// In the client: a stream of timelines, or Timeline.apply(timeline, change) one change at a time
|
|
132
|
+
const timelines = changes.pipe(Timeline.fold)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Your own events reach the client only when the server names them: `Timeline.changes(MyAssistant, { show: [OrderPlaced] })`.
|
|
136
|
+
|
|
137
|
+
### Blocks: answers and results
|
|
138
|
+
|
|
139
|
+
A block is UI under a message. One with an `answer` schema asks the user something; their click comes back as an
|
|
140
|
+
input. One made from a tool shows that tool's result, so the model never copies ids or signed links into props:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const PickOne = Block.make("pick_one", {
|
|
144
|
+
description: "Options to pick from, when the user chooses between a few.",
|
|
145
|
+
props: Schema.Struct({ options: Schema.Array(Schema.String) }),
|
|
146
|
+
answer: Schema.Struct({ picked: Schema.String })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const Blocks = BlockKit.make([PickOne, Block.fromToolResult(SearchItems)])
|
|
150
|
+
export const MyAssistant = Assistant.make("my-assistant", { blocks: Blocks })
|
|
151
|
+
|
|
152
|
+
// In the client: a renderer per kind, checked by the type system.
|
|
153
|
+
const render = BlockKit.toClient<typeof Blocks, ReactNode>(Blocks, {
|
|
154
|
+
pick_one: ({ props, open, respond }) => <Options options={props.options} disabled={!open} onPick={(picked) => respond({ picked })} />,
|
|
155
|
+
search_items: ({ result }) => <ItemCards items={result.items} />
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Ask before acting
|
|
160
|
+
|
|
161
|
+
A tool made with Effect's `needsApproval` doesn't run when the model calls it. The turn ends, the timeline shows the
|
|
162
|
+
call with the assistant's message beside it, and the user's decision is an input; an approved call runs then, once.
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const PlaceOrder = Tool.make("place_order", {
|
|
166
|
+
description: "Places an order. The user approves it first; say what you're asking them to approve.",
|
|
167
|
+
parameters: Schema.Struct({ sku: Schema.String, quantity: Schema.Int }),
|
|
168
|
+
success: Schema.Struct({ orderId: Schema.String }),
|
|
169
|
+
needsApproval: true,
|
|
170
|
+
dependencies: [AssistantToolCall.AssistantToolCall] // its idempotency key, so a re-run places it once
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// In the client
|
|
174
|
+
submit(ToolApproval.approve(item)) // or ToolApproval.reject(item, { reason })
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### History is a view
|
|
178
|
+
|
|
179
|
+
What the model reads is a function of the log. Long chats store a summary as one of your events, and the model
|
|
180
|
+
reads the last summary plus what came after; the log keeps everything, so the user still sees the whole chat.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
turn.run({ instructions, history: Compaction.since(Schema.is(Summary)) })
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### And more
|
|
187
|
+
|
|
188
|
+
- [Specialists](https://github.com/dbrxnds/effect-assistant/tree/main/apps/docs/guide/specialists.md): focused agents that do slow work in the background.
|
|
189
|
+
- [Skills and tool discovery](https://github.com/dbrxnds/effect-assistant/tree/main/apps/docs/guide/skills.md): playbooks loaded on demand, and big toolkits behind `search_tools`.
|
|
190
|
+
- [Memory](https://github.com/dbrxnds/effect-assistant/tree/main/apps/docs/guide/memory.md): facts kept between chats, in a store you implement.
|
|
191
|
+
- [Your own events](https://github.com/dbrxnds/effect-assistant/tree/main/apps/docs/guide/app-events.md): webhooks and workflows that report into a chat.
|
|
192
|
+
- [Chats](https://github.com/dbrxnds/effect-assistant/tree/main/apps/docs/guide/chats.md): which chats exist, who may use them, and deleting them.
|
|
193
|
+
- [Testing](https://github.com/dbrxnds/effect-assistant/tree/main/apps/docs/guide/testing.md): a scripted model, so tests need no API key.
|
|
194
|
+
|
|
195
|
+
## Hosting
|
|
196
|
+
|
|
197
|
+
In production each chat runs as an Effect Cluster entity, so one process writes each chat and a crashed turn
|
|
198
|
+
resumes, and the log goes to SQL:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
const AppLive = MyAssistantLive.pipe(
|
|
202
|
+
Layer.provide(ModelLive),
|
|
203
|
+
Layer.provideMerge(AssistantEngine.layerCluster),
|
|
204
|
+
Layer.provideMerge(
|
|
205
|
+
Layer.merge(ChatLog.layerSql(), SingleRunner.layer({ runnerStorage: "sql" }).pipe(Layer.provide(NodeCrypto.layer)))
|
|
206
|
+
),
|
|
207
|
+
Layer.provide(PgClient.layerConfig({ url: Config.Redacted("DATABASE_URL") }))
|
|
208
|
+
)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
```sql
|
|
212
|
+
CREATE TABLE chat_log (
|
|
213
|
+
chat_id TEXT NOT NULL,
|
|
214
|
+
sequence INTEGER NOT NULL,
|
|
215
|
+
at DOUBLE PRECISION NOT NULL,
|
|
216
|
+
event TEXT NOT NULL, -- LONGTEXT in MySQL
|
|
217
|
+
PRIMARY KEY (chat_id, sequence)
|
|
218
|
+
);
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
- `ChatLog.layerSql({ table: "my_chat_log" })` uses a table of your naming.
|
|
222
|
+
- `ChatLog.make(storage)` stores the log anywhere else (an ORM, another client), with `ChatLog.EventJson` for the
|
|
223
|
+
same JSON.
|
|
224
|
+
- A process that only serves clients provides `MyAssistant.layerClient` with `AssistantEngine.layerClusterClient`:
|
|
225
|
+
no handler, no log, and its calls reach the runners.
|
|
226
|
+
|
|
227
|
+
See [deploying](https://github.com/dbrxnds/effect-assistant/tree/main/apps/docs/guide/deploying.md).
|
|
228
|
+
|
|
229
|
+
## Contributing
|
|
230
|
+
|
|
231
|
+
See [CONTRIBUTING.md](https://github.com/dbrxnds/effect-assistant/blob/main/CONTRIBUTING.md).
|
|
232
|
+
|
|
233
|
+
| Path | What |
|
|
234
|
+
| --------------------------- | --------------------------------------------------------------------------------------- |
|
|
235
|
+
| `packages/effect-assistant` | The library. |
|
|
236
|
+
| `packages/demo/api` | A demo server for a fictional company, Acme: tools, orders from blocks, workflows, RPC. |
|
|
237
|
+
| `packages/demo/shared` | What the demo's web app and API share: RPCs, UI blocks, ids, demo data. |
|
|
238
|
+
| `apps/web` | The demo's chat UI. |
|
|
239
|
+
| `apps/docs` | The docs site, with the guides and the API reference generated from the JSDoc. |
|
|
18
240
|
|
|
19
|
-
```
|
|
20
|
-
pnpm
|
|
21
|
-
pnpm
|
|
22
|
-
pnpm
|
|
241
|
+
```sh
|
|
242
|
+
pnpm install
|
|
243
|
+
pnpm dev # demo API on :8787 (in memory, or Postgres with DATABASE_URL), web on :3000
|
|
244
|
+
pnpm docs:dev # docs on :5173
|
|
245
|
+
pnpm check # format, lint, and type check
|
|
246
|
+
pnpm test # on a scripted model, no API key needed
|
|
247
|
+
pnpm test:contract # against a real model (ANTHROPIC_API_KEY in .env.local)
|
|
23
248
|
```
|
package/package.json
CHANGED