| 1 | /** |
| 2 | * @title Stateful chat sessions |
| 3 | * |
| 4 | * The AI `Chat` module maintains conversation history automatically. Build |
| 5 | * AI agents or chat assistants. |
| 6 | */ |
| 7 | import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai" |
| 8 | import { Config, Context, DateTime, Effect, Layer, Ref, Schema } from "effect" |
| 9 | import { AiError, Chat, Prompt, Tool, Toolkit } from "effect/ai" |
| 10 | import { FetchHttpClient } from "effect/http" |
| 11 | |
| 12 | // --------------------------------------------------------------------------- |
| 13 | // Provider setup |
| 14 | // --------------------------------------------------------------------------- |
| 15 | |
| 16 | const OpenAiClientLayer = OpenAiClient.layerConfig({ |
| 17 | apiKey: Config.Redacted("OPENAI_API_KEY") |
| 18 | }).pipe(Layer.provide(FetchHttpClient.layer)) |
| 19 | |
| 20 | // --------------------------------------------------------------------------- |
| 21 | // Tools for the agentic loop |
| 22 | // --------------------------------------------------------------------------- |
| 23 | |
| 24 | const Tools = Toolkit.make(Tool.make("getCurrentTime", { |
| 25 | description: "Get the current time in ISO format", |
| 26 | parameters: Schema.Struct({ |
| 27 | id: Schema.String |
| 28 | }), |
| 29 | success: Schema.String |
| 30 | })) |
| 31 | |
| 32 | const ToolsLayer = Tools.toLayer(Effect.gen(function*() { |
| 33 | yield* Effect.logDebug("Initializing tools...") |
| 34 | return Tools.of({ |
| 35 | getCurrentTime: Effect.fn("Tools.getCurrentTime")(function*(_) { |
| 36 | const now = yield* DateTime.now |
| 37 | return DateTime.formatIso(now) |
| 38 | }) |
| 39 | }) |
| 40 | })) |
| 41 | |
| 42 | // --------------------------------------------------------------------------- |
| 43 | // Service that wraps Chat for a domain use-case |
| 44 | // --------------------------------------------------------------------------- |
| 45 | |
| 46 | export class AiAssistantError extends Schema.TaggedError<AiAssistantError>()("AiAssistantError", { |
| 47 | reason: AiError.AiErrorReason |
| 48 | }) { |
| 49 | static fromAiError(error: AiError.AiError) { |
| 50 | return new AiAssistantError({ reason: error.reason }) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | export class AiAssistant extends Context.Service<AiAssistant, { |
| 55 | // Send a message while maintaining conversation history across turns. |
| 56 | chat(message: string): Effect.Effect<string, AiAssistantError> |
| 57 | // Ask a question and use an agentic loop with tool calls to answer it. |
| 58 | agent(question: string): Effect.Effect<string, AiAssistantError> |
| 59 | }>()("acme/AiAssistant") { |
| 60 | static readonly layer = Layer.effect( |
| 61 | AiAssistant, |
| 62 | Effect.gen(function*() { |
| 63 | // Choose the model you want to use for the chat sessions. |
| 64 | const modelLayer = yield* OpenAiLanguageModel.model("gpt-5.2").captureRequirements |
| 65 | |
| 66 | // --------------------------------------------------------------------------- |
| 67 | // 1. Chat.empty — basic multi-turn conversation |
| 68 | // --------------------------------------------------------------------------- |
| 69 | |
| 70 | // Create a new chat session with `Chat.empty` or `Chat.fromPrompt`. The |
| 71 | // session maintains conversation history automatically, so you can focus on |
| 72 | // the current turn without having to manage context. |
| 73 | const newSession = yield* Chat.fromPrompt(Prompt.empty.pipe( |
| 74 | Prompt.setSystem("You are a helpful assistant that answers questions.") |
| 75 | )) |
| 76 | |
| 77 | // You can also create a chat using a json export. |
| 78 | const json = yield* newSession.exportJson |
| 79 | const session = yield* Chat.fromJson(json) |
| 80 | |
| 81 | const chat = Effect.fn("AiAssistant.chat")( |
| 82 | function*(message: string) { |
| 83 | // Create a new turn in the conversation by passing the user's message |
| 84 | // to `session.generateText`. |
| 85 | const response = yield* session.generateText({ prompt: message }).pipe( |
| 86 | // Provide the model layer to use. |
| 87 | // You could potentially use different models for different turns, |
| 88 | // or even switch models in the middle of a conversation. |
| 89 | Effect.provide(modelLayer) |
| 90 | ) |
| 91 | |
| 92 | // You can inspect the accumulated history at any point through the |
| 93 | // `history` ref on the chat instance. |
| 94 | const history = yield* Ref.get(session.history) |
| 95 | yield* Effect.logInfo( |
| 96 | `Conversation has ${history.content.length} messages` |
| 97 | ) |
| 98 | |
| 99 | return response.text |
| 100 | }, |
| 101 | Effect.mapError((error) => AiAssistantError.fromAiError(error)) |
| 102 | ) |
| 103 | |
| 104 | // --------------------------------------------------------------------------- |
| 105 | // 2. Create agentic loops with tools |
| 106 | // --------------------------------------------------------------------------- |
| 107 | |
| 108 | const tools = yield* Tools |
| 109 | const agent = Effect.fn("AiAssistant.agent")( |
| 110 | function*(question: string) { |
| 111 | // We start the agent with a system prompt and the user question. The |
| 112 | // agent can then call tools in a loop until it decides to return a |
| 113 | // final answer. |
| 114 | const session = yield* Chat.fromPrompt([ |
| 115 | { role: "system", content: "You are an assistant that can use tools to answer questions." }, |
| 116 | { role: "user", content: question } |
| 117 | ]) |
| 118 | |
| 119 | while (true) { |
| 120 | const response = yield* session.generateText({ |
| 121 | prompt: [], // No additional prompt — the model has full access to the conversation history |
| 122 | toolkit: tools // Provide the tools to the model |
| 123 | }).pipe( |
| 124 | // Provide the model layer to use. |
| 125 | // You could potentially use different models for different turns, |
| 126 | // or even switch models in the middle of a conversation. |
| 127 | Effect.provide(modelLayer) |
| 128 | ) |
| 129 | if (response.toolCalls.length > 0) { |
| 130 | // If the model called any tools, execute them and the Chat module |
| 131 | // will automatically add the tool results to the conversation |
| 132 | // history before the next turn. |
| 133 | continue |
| 134 | } |
| 135 | // If there are no tool calls, the model has returned a final answer |
| 136 | // and we can exit the loop. |
| 137 | return response.text |
| 138 | } |
| 139 | }, |
| 140 | // Remap AI errors to our domain-specific error type, but die on |
| 141 | // unexpected errors. |
| 142 | Effect.catchTag( |
| 143 | "AiError", |
| 144 | (error) => Effect.fail(AiAssistantError.fromAiError(error)), |
| 145 | (e) => Effect.die(e) |
| 146 | ) |
| 147 | ) |
| 148 | |
| 149 | return AiAssistant.of({ |
| 150 | chat, |
| 151 | agent |
| 152 | }) |
| 153 | }) |
| 154 | ).pipe( |
| 155 | // Provide the OpenAI client and tools layers to the AiAssistant service. |
| 156 | Layer.provide([OpenAiClientLayer, ToolsLayer]) |
| 157 | ) |
| 158 | } |