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.
Files changed (2) hide show
  1. package/README.md +240 -15
  2. 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
- A chat assistant on [Effect](https://effect.website) that behaves like a
4
- colleague. It keeps an append-only event log per chat, runs an
5
- interruptible agent loop over it, and talks to the user only through a
6
- `send_user_message` tool. It also supports specialists, skills, UI blocks,
7
- memory, and compaction. It runs in memory or as one Effect
8
- Cluster entity per chat.
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
- ```bash
24
+ ```sh
11
25
  pnpm add effect-assistant effect@^4.0.0 @effect/ai-anthropic@^4.0.0
12
26
  ```
13
27
 
14
- The [README on GitHub](https://github.com/dbrxnds/effect-assistant#readme)
15
- has the simplest setup and the more extensive ones. The
16
- [docs](https://github.com/dbrxnds/effect-assistant/tree/main/apps/docs) have
17
- a guide per topic and the API reference.
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
- ```bash
20
- pnpm --filter effect-assistant test # the mechanics, on a scripted model
21
- pnpm --filter effect-assistant test:contract # against a real model (ANTHROPIC_API_KEY in .env.local)
22
- pnpm --filter effect-assistant build # dist, with declarations and source maps
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-assistant",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "A colleague assistant on Effect: an append-only event log per chat, an interruptible agent loop, specialists, skills, and UI blocks",
5
5
  "license": "MIT",
6
6
  "repository": {