opencode-effect-enforcer 0.2.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 +278 -0
- package/guidance/effect-first-development.md +1247 -0
- package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
- package/guidance/post__parse-dont-validate.md +109 -0
- package/guidance/progressive-disclosure-guidance.md +38 -0
- package/package.json +63 -0
- package/patterns/avoid-any.md +37 -0
- package/patterns/avoid-data-tagged-error.md +34 -0
- package/patterns/avoid-direct-json.md +51 -0
- package/patterns/avoid-direct-tag-checks.md +54 -0
- package/patterns/avoid-expect-in-if.md +52 -0
- package/patterns/avoid-mutable-state.md +70 -0
- package/patterns/avoid-native-fetch.md +61 -0
- package/patterns/avoid-node-imports.md +86 -0
- package/patterns/avoid-non-null-assertion.md +44 -0
- package/patterns/avoid-object-type.md +46 -0
- package/patterns/avoid-option-getorthrow.md +39 -0
- package/patterns/avoid-platform-coupling.md +43 -0
- package/patterns/avoid-process-env.md +43 -0
- package/patterns/avoid-react-hooks.md +73 -0
- package/patterns/avoid-schema-suffix.md +45 -0
- package/patterns/avoid-sync-fs.md +68 -0
- package/patterns/avoid-try-catch.md +47 -0
- package/patterns/avoid-ts-ignore.md +38 -0
- package/patterns/avoid-untagged-errors.md +67 -0
- package/patterns/avoid-yield-ref.md +46 -0
- package/patterns/casting-awareness.md +46 -0
- package/patterns/context-tag-extends.md +84 -0
- package/patterns/effect-catchall-default.md +61 -0
- package/patterns/effect-promise-vs-trypromise.md +47 -0
- package/patterns/effect-run-in-body.md +58 -0
- package/patterns/imperative-loops.md +76 -0
- package/patterns/prefer-arr-sort.md +52 -0
- package/patterns/prefer-duration-values.md +56 -0
- package/patterns/prefer-effect-fn.md +161 -0
- package/patterns/prefer-match-over-switch.md +48 -0
- package/patterns/prefer-option-over-null.md +56 -0
- package/patterns/prefer-redacted-config.md +70 -0
- package/patterns/prefer-schema-class.md +54 -0
- package/patterns/require-effect-concurrency.md +83 -0
- package/patterns/stream-large-files.md +63 -0
- package/patterns/throw-in-effect-gen.md +62 -0
- package/patterns/use-clock-service.md +45 -0
- package/patterns/use-command-executor-service.md +54 -0
- package/patterns/use-console-service.md +54 -0
- package/patterns/use-filesystem-service.md +59 -0
- package/patterns/use-http-client-service.md +77 -0
- package/patterns/use-path-service.md +53 -0
- package/patterns/use-random-service.md +45 -0
- package/patterns/use-temp-file-scoped.md +66 -0
- package/patterns/vm-in-wrong-file.md +51 -0
- package/patterns/yield-in-for-loop.md +61 -0
- package/skills/effect-ai-chat/SKILL.md +472 -0
- package/skills/effect-ai-language-model/SKILL.md +652 -0
- package/skills/effect-ai-prompt/SKILL.md +752 -0
- package/skills/effect-ai-provider/SKILL.md +668 -0
- package/skills/effect-ai-streaming/SKILL.md +418 -0
- package/skills/effect-ai-tool/SKILL.md +1132 -0
- package/skills/effect-atom-rpc/SKILL.md +488 -0
- package/skills/effect-atom-state/SKILL.md +640 -0
- package/skills/effect-batching/SKILL.md +614 -0
- package/skills/effect-cache/SKILL.md +570 -0
- package/skills/effect-cli/SKILL.md +523 -0
- package/skills/effect-command-executor/SKILL.md +675 -0
- package/skills/effect-concurrency-testing/SKILL.md +612 -0
- package/skills/effect-config/SKILL.md +580 -0
- package/skills/effect-context-witness/SKILL.md +274 -0
- package/skills/effect-domain-modeling/SKILL.md +1212 -0
- package/skills/effect-domain-predicates/SKILL.md +867 -0
- package/skills/effect-error-handling/SKILL.md +1581 -0
- package/skills/effect-fiber/SKILL.md +731 -0
- package/skills/effect-filesystem/SKILL.md +624 -0
- package/skills/effect-graph/SKILL.md +571 -0
- package/skills/effect-http-api/SKILL.md +1760 -0
- package/skills/effect-http-client/SKILL.md +989 -0
- package/skills/effect-http-server/SKILL.md +920 -0
- package/skills/effect-incremental-migration/SKILL.md +362 -0
- package/skills/effect-layer-design/SKILL.md +642 -0
- package/skills/effect-managed-runtime/SKILL.md +395 -0
- package/skills/effect-mcp-server/SKILL.md +608 -0
- package/skills/effect-observability/SKILL.md +719 -0
- package/skills/effect-optics/SKILL.md +554 -0
- package/skills/effect-parallelization/SKILL.md +668 -0
- package/skills/effect-path/SKILL.md +296 -0
- package/skills/effect-pattern-matching/SKILL.md +914 -0
- package/skills/effect-platform-abstraction/SKILL.md +1175 -0
- package/skills/effect-platform-layers/SKILL.md +514 -0
- package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
- package/skills/effect-react-composition/SKILL.md +986 -0
- package/skills/effect-react-vm/SKILL.md +675 -0
- package/skills/effect-rpc-api/SKILL.md +624 -0
- package/skills/effect-rpc-client/SKILL.md +666 -0
- package/skills/effect-rpc-cluster/SKILL.md +1623 -0
- package/skills/effect-rpc-server/SKILL.md +767 -0
- package/skills/effect-scheduling/SKILL.md +124 -0
- package/skills/effect-schema-composition/SKILL.md +975 -0
- package/skills/effect-schema-v4/SKILL.md +691 -0
- package/skills/effect-scope/SKILL.md +682 -0
- package/skills/effect-service-implementation/SKILL.md +656 -0
- package/skills/effect-socket/SKILL.md +703 -0
- package/skills/effect-sql/SKILL.md +781 -0
- package/skills/effect-stream/SKILL.md +765 -0
- package/skills/effect-testing/SKILL.md +1331 -0
- package/skills/effect-typeclass-design/SKILL.md +161 -0
- package/skills/effect-wide-events/Article.md +66 -0
- package/skills/effect-wide-events/SKILL.md +95 -0
- package/skills/effect-workflow/SKILL.md +810 -0
- package/src/agent-policy.ts +22 -0
- package/src/enforcer.ts +104 -0
- package/src/frontmatter.ts +34 -0
- package/src/guidance.ts +66 -0
- package/src/index.ts +38 -0
- package/src/pattern-catalog.ts +115 -0
- package/src/pattern-matcher.ts +178 -0
- package/src/pattern.ts +97 -0
- package/src/skills.ts +29 -0
- package/src/write-projection.ts +66 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
action: context
|
|
3
|
+
tool: (edit|write)
|
|
4
|
+
event: after
|
|
5
|
+
name: vm-in-wrong-file
|
|
6
|
+
description: View Model definitions must be in .vm.ts files - detected VM pattern outside of proper location
|
|
7
|
+
glob: '**/!(*.vm).{ts,tsx}'
|
|
8
|
+
pattern: (interface\s+\w+VM\s*\{|Context\.(Service|GenericTag)<\w*VM>|Layer\.(effect|scoped)\(\s*\w+VM)
|
|
9
|
+
level: critical
|
|
10
|
+
suggestSkills:
|
|
11
|
+
- effect-react-vm
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# VM Code in Wrong File
|
|
15
|
+
|
|
16
|
+
```haskell
|
|
17
|
+
-- File structure convention
|
|
18
|
+
data ComponentFiles = ComponentFiles
|
|
19
|
+
{ component :: "Component.tsx" -- pure renderer
|
|
20
|
+
, viewModel :: "Component.vm.ts" -- VM definition
|
|
21
|
+
, index :: "index.ts" -- re-exports
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
-- VM file structure
|
|
25
|
+
data VMFile a = VMFile
|
|
26
|
+
{ interface :: Interface a -- type contract
|
|
27
|
+
, tag :: GenericTag a -- DI tag
|
|
28
|
+
, layer :: Layer a -- implementation
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```haskell
|
|
33
|
+
-- Anti-pattern: VM in component file
|
|
34
|
+
bad :: "Component.tsx"
|
|
35
|
+
bad = do
|
|
36
|
+
interface ComponentVM { ... } -- ✗ wrong file
|
|
37
|
+
ComponentVM = Context.Service -- ✗ wrong file
|
|
38
|
+
layer = Layer.effect(...) -- ✗ wrong file
|
|
39
|
+
|
|
40
|
+
-- Correct: VM in dedicated file
|
|
41
|
+
good :: "Component.vm.ts"
|
|
42
|
+
good = do
|
|
43
|
+
ComponentVM = Context.Service -- ✓ correct file (Effect v4 beta.46)
|
|
44
|
+
layer = Layer.effect(...) -- ✓ correct file
|
|
45
|
+
export default { service, layer } -- ✓ clean export
|
|
46
|
+
|
|
47
|
+
-- Import in component
|
|
48
|
+
import ComponentVM from "./Component.vm"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
VMs must be in `.vm.ts` files. Mixing rendering and state management breaks organization. Invoke `react-vm` skill for guidance.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
action: context
|
|
3
|
+
tool: (edit|write)
|
|
4
|
+
event: after
|
|
5
|
+
name: yield-in-for-loop
|
|
6
|
+
description: Use Effect.forEach or STM.forEach instead of yield* in for loops
|
|
7
|
+
glob: '**/*.{ts,tsx}'
|
|
8
|
+
detector: ast
|
|
9
|
+
rule:
|
|
10
|
+
pattern: yield* $A
|
|
11
|
+
inside:
|
|
12
|
+
any:
|
|
13
|
+
- kind: for_statement
|
|
14
|
+
- kind: for_in_statement
|
|
15
|
+
stopBy: end
|
|
16
|
+
level: warning
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# Use `Effect.forEach` Instead of For Loops
|
|
20
|
+
|
|
21
|
+
```haskell
|
|
22
|
+
-- Transformation
|
|
23
|
+
for item in items { yield* process item } -- imperative, not composable
|
|
24
|
+
forEach items process -- declarative, parallelizable
|
|
25
|
+
|
|
26
|
+
-- Operations
|
|
27
|
+
forEach :: [a] → (a → Effect b) → Effect [b]
|
|
28
|
+
filter :: [a] → (a → Effect Bool) → Effect [a]
|
|
29
|
+
traverse :: (a → Effect b) → [a] → Effect [b]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```haskell
|
|
33
|
+
-- Pattern
|
|
34
|
+
bad :: [Item] → Effect ()
|
|
35
|
+
bad items = for item ← items do
|
|
36
|
+
yield* processItem item -- imperative loop
|
|
37
|
+
|
|
38
|
+
good :: [Item] → Effect ()
|
|
39
|
+
good items = forEach items processItem
|
|
40
|
+
|
|
41
|
+
parallel :: [Item] → Effect ()
|
|
42
|
+
parallel items = forEach items processItem { concurrency: "unbounded" }
|
|
43
|
+
|
|
44
|
+
-- With filtering
|
|
45
|
+
filtered :: [Id] → Effect ()
|
|
46
|
+
filtered ids = do
|
|
47
|
+
active ← filter ids (not <<< alreadyProcessed)
|
|
48
|
+
forEach active process
|
|
49
|
+
|
|
50
|
+
-- Effectful predicate
|
|
51
|
+
effectfulFilter :: [User] → Effect ()
|
|
52
|
+
effectfulFilter users = do
|
|
53
|
+
active ← Effect.filter users (checkUserStatus <<< _.id)
|
|
54
|
+
forEach active sendNotification
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
For loops with `yield*` are imperative. `Effect.forEach` enables parallel execution, uniform error handling, and composition.
|
|
58
|
+
|
|
59
|
+
All three for-statement shapes are flagged: classic `for (i; cond; step)`, `for...in`, and `for...of`. The `for...of` form is the most common way this anti-pattern appears in Effect code (`for (const item of items) yield* process(item)`) — it loses concurrency control and uniform error handling just like the others.
|
|
60
|
+
|
|
61
|
+
Note: in tree-sitter TypeScript, `for ... of` parses as `for_in_statement` (the kinds are shared), so listing `for_in_statement` covers both `for...in` and `for...of`.
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: effect-ai-chat
|
|
3
|
+
description: Build stateful AI chat sessions with the Effect Chat module. Use this skill when implementing multi-turn conversations, agentic tool-calling loops, chat persistence, streaming chat responses, or structured object generation within a conversation context.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are an Effect TypeScript expert specializing in the `Chat` module for stateful AI conversations.
|
|
7
|
+
|
|
8
|
+
## Effect Source Reference
|
|
9
|
+
|
|
10
|
+
The Effect v4 source is available at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`.
|
|
11
|
+
Browse and read files there directly to look up APIs, types, and implementations.
|
|
12
|
+
|
|
13
|
+
Reference this for:
|
|
14
|
+
|
|
15
|
+
- Chat module source: `packages/effect/src/unstable/ai/Chat.ts`
|
|
16
|
+
- Chat usage examples: `ai-docs/src/71_ai/30_chat.ts`
|
|
17
|
+
- Tool integration examples: `ai-docs/src/71_ai/20_tools.ts`
|
|
18
|
+
- Prompt construction: `packages/effect/src/unstable/ai/Prompt.ts`
|
|
19
|
+
|
|
20
|
+
## Core Imports
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Effect, Layer, Ref, Schema, Context, Stream } from 'effect';
|
|
24
|
+
import {
|
|
25
|
+
Chat,
|
|
26
|
+
Prompt,
|
|
27
|
+
LanguageModel,
|
|
28
|
+
Tool,
|
|
29
|
+
Toolkit,
|
|
30
|
+
AiError
|
|
31
|
+
} from 'effect/unstable/ai';
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## What Chat Provides
|
|
35
|
+
|
|
36
|
+
The `Chat` module wraps `LanguageModel` with automatic conversation history management. Each `Chat` instance:
|
|
37
|
+
|
|
38
|
+
- Maintains a `Ref<Prompt.Prompt>` of accumulated messages
|
|
39
|
+
- Serializes calls via an internal semaphore (one generation at a time)
|
|
40
|
+
- Automatically appends user prompts and model responses to history
|
|
41
|
+
- Supports `generateText`, `streamText`, and `generateObject`
|
|
42
|
+
- Provides `export` / `exportJson` for serialization and `fromExport` / `fromJson` for restoration
|
|
43
|
+
|
|
44
|
+
## Creating Sessions
|
|
45
|
+
|
|
46
|
+
### Empty session
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const session = yield* Chat.empty;
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### With a system prompt
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const session =
|
|
56
|
+
yield*
|
|
57
|
+
Chat.fromPrompt(
|
|
58
|
+
Prompt.empty.pipe(Prompt.setSystem('You are a helpful assistant.'))
|
|
59
|
+
);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### From raw message array
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const session =
|
|
66
|
+
yield*
|
|
67
|
+
Chat.fromPrompt([
|
|
68
|
+
{ role: 'system', content: 'You are an assistant that can use tools.' },
|
|
69
|
+
{ role: 'user', content: 'Hello!' }
|
|
70
|
+
]);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### From serialized JSON (restoring a session)
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const session = yield* Chat.fromJson(savedJsonString);
|
|
77
|
+
// Or from structured data:
|
|
78
|
+
const session = yield* Chat.fromExport(savedData);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Generating Text (Single Turn)
|
|
82
|
+
|
|
83
|
+
Call `session.generateText` with a prompt. The prompt is concatenated with accumulated history, sent to the model, and the response is appended to history automatically.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const response =
|
|
87
|
+
yield*
|
|
88
|
+
session
|
|
89
|
+
.generateText({
|
|
90
|
+
prompt: 'What is the capital of France?'
|
|
91
|
+
})
|
|
92
|
+
.pipe(Effect.provide(modelLayer));
|
|
93
|
+
|
|
94
|
+
// response.text — the model's text reply
|
|
95
|
+
// response.content — array of response parts
|
|
96
|
+
// response.toolCalls — any tool calls the model made
|
|
97
|
+
// response.toolResults — resolved tool results
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Providing the model
|
|
101
|
+
|
|
102
|
+
The `generateText` method requires `LanguageModel.LanguageModel` in its context. Provide it per-call or at the layer level:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
// Per-call — allows switching models between turns
|
|
106
|
+
const modelLayer = OpenAiLanguageModel.model('gpt-5.2');
|
|
107
|
+
yield*
|
|
108
|
+
session.generateText({ prompt: '...' }).pipe(Effect.provide(modelLayer));
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Passing prompt as string vs Prompt
|
|
112
|
+
|
|
113
|
+
The `prompt` option accepts `Prompt.RawInput` — a string, an iterable of encoded messages, or a `Prompt.Prompt`. Internally, `Prompt.make(options.prompt)` is called; strings become user text messages and encoded message iterables are decoded.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
// String shorthand
|
|
117
|
+
yield* session.generateText({ prompt: 'Hello' });
|
|
118
|
+
|
|
119
|
+
// Empty prompt (continue from history alone, useful in agentic loops)
|
|
120
|
+
yield* session.generateText({ prompt: [] });
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Streaming Text
|
|
124
|
+
|
|
125
|
+
`streamText` returns a `Stream` of `Response.StreamPart` values. History is updated when the stream finalizes. Consume the stream to completion if the full assistant response should become history; if the stream is interrupted early, only the parts emitted before finalization are recorded.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
yield*
|
|
129
|
+
session
|
|
130
|
+
.streamText({
|
|
131
|
+
prompt: 'Write a story about space'
|
|
132
|
+
})
|
|
133
|
+
.pipe(
|
|
134
|
+
Stream.runForEach((part) =>
|
|
135
|
+
part.type === 'text-delta'
|
|
136
|
+
? Effect.sync(() => process.stdout.write(part.delta))
|
|
137
|
+
: Effect.void
|
|
138
|
+
),
|
|
139
|
+
Effect.provide(modelLayer)
|
|
140
|
+
);
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Generating Structured Objects
|
|
144
|
+
|
|
145
|
+
`generateObject` forces the model to return data conforming to a schema:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const ContactSchema = Schema.Struct({
|
|
149
|
+
name: Schema.String,
|
|
150
|
+
email: Schema.String,
|
|
151
|
+
phone: Schema.optional(Schema.String)
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const result =
|
|
155
|
+
yield*
|
|
156
|
+
session
|
|
157
|
+
.generateObject({
|
|
158
|
+
prompt: 'Extract: John Doe, john@example.com, 555-1234',
|
|
159
|
+
schema: ContactSchema
|
|
160
|
+
})
|
|
161
|
+
.pipe(Effect.provide(modelLayer));
|
|
162
|
+
|
|
163
|
+
// result.value — typed as { name: string; email: string; phone?: string }
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Conversation History
|
|
167
|
+
|
|
168
|
+
History is stored in `session.history`, a `Ref<Prompt.Prompt>`:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// Read current history
|
|
172
|
+
const history = yield* Ref.get(session.history);
|
|
173
|
+
console.log(`${history.content.length} messages in conversation`);
|
|
174
|
+
|
|
175
|
+
// Manually inspect messages
|
|
176
|
+
for (const msg of history.content) {
|
|
177
|
+
console.log(msg.role, msg);
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Use `Prompt.fromResponseParts(response.content)` when manually folding model output back into history. It preserves text and reasoning, tool calls/results, approval requests, and skips preliminary tool results. Framework-executed final tool results become tool messages using `encodedResult`; provider-executed final tool results stay in the assistant message.
|
|
182
|
+
|
|
183
|
+
## Persistence: Export and Restore
|
|
184
|
+
|
|
185
|
+
### Export to JSON
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
const json = yield* session.exportJson;
|
|
189
|
+
// json is a string — store in database, file system, localStorage, etc.
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Export to structured data
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
const data = yield* session.export;
|
|
196
|
+
// data is `unknown` — the raw encoded form
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Restore from JSON
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
const restored = yield* Chat.fromJson(json);
|
|
203
|
+
// Continue conversation from where it left off
|
|
204
|
+
yield* restored.generateText({ prompt: 'What were we discussing?' });
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Restore from structured data
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
const restored = yield* Chat.fromExport(data);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Chat Persistence Service
|
|
214
|
+
|
|
215
|
+
For automatic persistence (save after every generation), use `Chat.Persistence`:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
import { Persistence } from 'effect/unstable/persistence';
|
|
219
|
+
|
|
220
|
+
// Create a persistence layer and provide a BackingPersistence implementation
|
|
221
|
+
const PersistenceLayer = Chat.layerPersisted({ storeId: 'my-chats' }).pipe(
|
|
222
|
+
Layer.provide(Persistence.layerBackingMemory)
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Usage
|
|
226
|
+
const program = Effect.gen(function* () {
|
|
227
|
+
const persistence = yield* Chat.Persistence;
|
|
228
|
+
|
|
229
|
+
// Get existing chat or create new one
|
|
230
|
+
const chat = yield* persistence.getOrCreate('session-123', {
|
|
231
|
+
timeToLive: '1 hour' // optional TTL
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// chat is a `Chat.Persisted` — same API as Chat.Service but auto-saves
|
|
235
|
+
const response = yield* chat
|
|
236
|
+
.generateText({
|
|
237
|
+
prompt: 'Hello!'
|
|
238
|
+
})
|
|
239
|
+
.pipe(Effect.provide(modelLayer));
|
|
240
|
+
|
|
241
|
+
// History is automatically saved to the backing store after generation
|
|
242
|
+
|
|
243
|
+
// Manual save if needed
|
|
244
|
+
yield* chat.save;
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The `Persisted` interface extends `Chat.Service` with:
|
|
249
|
+
|
|
250
|
+
- `id: string` — the chat identifier in the store
|
|
251
|
+
- `save: Effect<void, AiError | PersistenceError>` — manual save trigger
|
|
252
|
+
|
|
253
|
+
Provide a `Persistence.BackingPersistence` implementation (e.g., key-value store, database adapter) to the persistence layer.
|
|
254
|
+
|
|
255
|
+
## Tool Integration (Agentic Loops)
|
|
256
|
+
|
|
257
|
+
Pass a toolkit to `generateText` to enable tool calling. The Chat module manages the full conversation context including tool call/result messages.
|
|
258
|
+
|
|
259
|
+
### Define tools and toolkit
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
const Tools = Toolkit.make(
|
|
263
|
+
Tool.make('getCurrentTime', {
|
|
264
|
+
description: 'Get the current time in ISO format',
|
|
265
|
+
parameters: Schema.Struct({ id: Schema.String }),
|
|
266
|
+
success: Schema.String
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const ToolsLayer = Tools.toLayer(
|
|
271
|
+
Effect.gen(function* () {
|
|
272
|
+
return Tools.of({
|
|
273
|
+
getCurrentTime: Effect.fn('Tools.getCurrentTime')(function* (_) {
|
|
274
|
+
const now = yield* DateTime.now;
|
|
275
|
+
return DateTime.formatIso(now);
|
|
276
|
+
})
|
|
277
|
+
});
|
|
278
|
+
})
|
|
279
|
+
);
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Agentic loop pattern
|
|
283
|
+
|
|
284
|
+
The model calls tools, results are added to history, and you loop until the model returns a final text answer:
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
const agent = Effect.fn('agent')(function* (question: string) {
|
|
288
|
+
const tools = yield* Tools;
|
|
289
|
+
const session = yield* Chat.fromPrompt([
|
|
290
|
+
{ role: 'system', content: 'You are an assistant that can use tools.' },
|
|
291
|
+
{ role: 'user', content: question }
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
let nextPrompt: Prompt.RawInput = [];
|
|
295
|
+
|
|
296
|
+
while (true) {
|
|
297
|
+
const response = yield* session
|
|
298
|
+
.generateText({
|
|
299
|
+
prompt: nextPrompt, // Empty after the first turn unless approving tools
|
|
300
|
+
toolkit: tools // Provide tools for this turn
|
|
301
|
+
})
|
|
302
|
+
.pipe(Effect.provide(modelLayer));
|
|
303
|
+
|
|
304
|
+
nextPrompt = [];
|
|
305
|
+
|
|
306
|
+
const approvalRequests = response.content.filter(
|
|
307
|
+
(part) => part.type === 'tool-approval-request'
|
|
308
|
+
);
|
|
309
|
+
if (approvalRequests.length > 0) {
|
|
310
|
+
// Append approval responses as a tool message, then call the model again.
|
|
311
|
+
nextPrompt = [
|
|
312
|
+
Prompt.toolMessage({
|
|
313
|
+
content: approvalRequests.map((request) =>
|
|
314
|
+
Prompt.toolApprovalResponsePart({
|
|
315
|
+
approvalId: request.approvalId,
|
|
316
|
+
approved: true // Or false with a reason from policy/user review
|
|
317
|
+
})
|
|
318
|
+
)
|
|
319
|
+
})
|
|
320
|
+
];
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (response.toolCalls.length > 0) {
|
|
325
|
+
// Tools were called — results are already in history.
|
|
326
|
+
// Loop back so the model can see the results and decide next step.
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
// No tool calls or approval requests — model returned a final answer.
|
|
330
|
+
return response.text;
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Key points:
|
|
336
|
+
|
|
337
|
+
- Pass `prompt: []` (empty) after the first turn — the model already has the full conversation in history
|
|
338
|
+
- The `toolkit` option makes tools available to the model
|
|
339
|
+
- Tool calls and final tool results are automatically appended to history by `generateText`
|
|
340
|
+
- If a tool has `needsApproval`, the response may contain `tool-approval-request`; append `Prompt.toolApprovalResponsePart` in a tool message and call the model again
|
|
341
|
+
- The loop continues until the model stops calling tools and has no pending approvals
|
|
342
|
+
|
|
343
|
+
### Registry-Backed Tool Loops
|
|
344
|
+
|
|
345
|
+
In production coding-agent harnesses, a `ToolRegistry` service often sits above the raw toolkit. Let the registry decide which tools are available, how they are described, and which runtime policy applies for the current agent/session.
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
const registry = yield* ToolRegistry.Service;
|
|
349
|
+
const toolkit = yield* registry.toolkitFor(agentName);
|
|
350
|
+
const promptBlock = yield* registry.descriptionBlock(agentName);
|
|
351
|
+
|
|
352
|
+
const session =
|
|
353
|
+
yield* Chat.fromPrompt(Prompt.empty.pipe(Prompt.setSystem(promptBlock)));
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Prefer overriding registry handles or test layers in tests rather than spying on tool modules directly. That keeps tests aligned with production wiring.
|
|
357
|
+
|
|
358
|
+
## Wrapping Chat in a Domain Service
|
|
359
|
+
|
|
360
|
+
Use `Context.Service` to expose a clean domain API:
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
class AiAssistantError extends Schema.TaggedError<AiAssistantError>()(
|
|
364
|
+
'AiAssistantError',
|
|
365
|
+
{
|
|
366
|
+
reason: AiError.AiErrorReason
|
|
367
|
+
}
|
|
368
|
+
) {
|
|
369
|
+
static fromAiError(error: AiError.AiError) {
|
|
370
|
+
return new AiAssistantError({ reason: error.reason });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
class AiAssistant extends Context.Service<
|
|
375
|
+
AiAssistant,
|
|
376
|
+
{
|
|
377
|
+
chat(message: string): Effect.Effect<string, AiAssistantError>;
|
|
378
|
+
agent(question: string): Effect.Effect<string, AiAssistantError>;
|
|
379
|
+
}
|
|
380
|
+
>()('acme/AiAssistant') {
|
|
381
|
+
static readonly layer = Layer.effect(
|
|
382
|
+
AiAssistant,
|
|
383
|
+
Effect.gen(function* () {
|
|
384
|
+
const model = OpenAiLanguageModel.model('gpt-5.2');
|
|
385
|
+
const modelLayer = yield* model.captureRequirements;
|
|
386
|
+
|
|
387
|
+
// Session lives for the lifetime of the service
|
|
388
|
+
const session = yield* Chat.fromPrompt(
|
|
389
|
+
Prompt.empty.pipe(
|
|
390
|
+
Prompt.setSystem('You are a helpful assistant.')
|
|
391
|
+
)
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const chat = Effect.fn('AiAssistant.chat')(
|
|
395
|
+
function* (message: string) {
|
|
396
|
+
const response = yield* session
|
|
397
|
+
.generateText({
|
|
398
|
+
prompt: message
|
|
399
|
+
})
|
|
400
|
+
.pipe(Effect.provide(modelLayer));
|
|
401
|
+
return response.text;
|
|
402
|
+
},
|
|
403
|
+
Effect.mapError((error) => AiAssistantError.fromAiError(error))
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const tools = yield* Tools;
|
|
407
|
+
const agent = Effect.fn('AiAssistant.agent')(
|
|
408
|
+
function* (question: string) {
|
|
409
|
+
const agentSession = yield* Chat.fromPrompt([
|
|
410
|
+
{
|
|
411
|
+
role: 'system',
|
|
412
|
+
content: 'You are an assistant that can use tools.'
|
|
413
|
+
},
|
|
414
|
+
{ role: 'user', content: question }
|
|
415
|
+
]);
|
|
416
|
+
while (true) {
|
|
417
|
+
const response = yield* agentSession
|
|
418
|
+
.generateText({
|
|
419
|
+
prompt: [],
|
|
420
|
+
toolkit: tools
|
|
421
|
+
})
|
|
422
|
+
.pipe(Effect.provide(modelLayer));
|
|
423
|
+
if (response.toolCalls.length > 0) continue;
|
|
424
|
+
return response.text;
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
Effect.catchTag(
|
|
428
|
+
'AiError',
|
|
429
|
+
(error) => Effect.fail(AiAssistantError.fromAiError(error)),
|
|
430
|
+
(e) => Effect.die(e)
|
|
431
|
+
)
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
return AiAssistant.of({ chat, agent });
|
|
435
|
+
})
|
|
436
|
+
).pipe(Layer.provide([OpenAiClientLayer, ToolsLayer]));
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
## Error Handling
|
|
441
|
+
|
|
442
|
+
Chat operations produce `AiError.AiError` errors. Use `catchTag` with the three-argument form (v4 pattern):
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
Effect.catchTag(
|
|
446
|
+
'AiError',
|
|
447
|
+
(aiError) => Effect.fail(new MyDomainError({ reason: aiError.reason })),
|
|
448
|
+
(unexpectedError) => Effect.die(unexpectedError)
|
|
449
|
+
);
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
`Chat.fromJson` and `Chat.fromExport` produce `Schema.SchemaError` if the data is malformed.
|
|
453
|
+
|
|
454
|
+
## Concurrency
|
|
455
|
+
|
|
456
|
+
Each `Chat` instance uses an internal semaphore with 1 permit, ensuring that only one generation runs at a time per session. This prevents race conditions on the shared history ref. Create separate `Chat` instances for parallel conversations.
|
|
457
|
+
|
|
458
|
+
## Critical Rules
|
|
459
|
+
|
|
460
|
+
1. **Always provide `LanguageModel.LanguageModel`** — `generateText`, `streamText`, and `generateObject` all require it in context. Provide via `Effect.provide(modelLayer)`.
|
|
461
|
+
2. **Use `prompt: []` in agentic loops** — After the initial prompt, pass an empty prompt to let the model respond based on accumulated history including tool results.
|
|
462
|
+
3. **Import from `effect/unstable/ai`** — Chat, Prompt, Tool, Toolkit, LanguageModel, and AiError all come from this path.
|
|
463
|
+
4. **One session = one conversation** — Create separate `Chat` instances for independent conversations. Don't share a session across unrelated threads.
|
|
464
|
+
5. **Export before shutdown** — Use `exportJson` to persist state. Restore with `Chat.fromJson`.
|
|
465
|
+
6. **Provide toolkit handlers** — When using tools, the toolkit's handler layer must be provided (e.g., `Layer.provide(ToolsLayer)`).
|
|
466
|
+
|
|
467
|
+
## Anti-Patterns
|
|
468
|
+
|
|
469
|
+
- **Don't manually manage history** — Let Chat handle prompt concatenation. Don't manually `Ref.set` the history unless you have a specific advanced use case.
|
|
470
|
+
- **Don't use `LanguageModel.generateText` directly for multi-turn** — Use `Chat` instead; it handles history accumulation automatically.
|
|
471
|
+
- **Don't forget to provide the model layer** — Every `generateText`/`streamText`/`generateObject` call requires `LanguageModel.LanguageModel` in context.
|
|
472
|
+
- **Don't create a Chat per request if you want continuity** — Keep the session alive across turns for multi-turn conversation.
|