Liminal Overview beta
Liminal enables the expression of reusable conversations as effects.
import { Effect } from "effect"
import L from "liminal"
const conversation = Effect.gen(function*() {
// Append a user message.
yield* L.user`Where is the pot of gold?`
// Infer and append an assistant message.
const loc = yield* L.assistant
// Use the reply.
loc satisfies string
})
State Management
We yield*
Liminal effects to manage the underlying conversation state. The final conversation of the previous example may look as follows.
[
{
"role": "system",
"content": "You are a Leprechaun."
},
{
"role": "user",
"content": "Where is the pot of gold?"
},
{
"role": "assistant",
"content": "Under the rainbow."
}
]
Unifying Conversation and Control Flow
We reason about the progression of the conversation as one with ordinary function control flow. We can utilize ordinary control flow operators, such as for
and while
loops .
Effect.gen(function*() {
while (true) {
// Ask the assistant whether to move on.
yield* L.user`Are we done with this part of the conversation?`
const { finished } = yield* L.assistantSchema({
finished: Schema.Boolean,
})
// If finished, move onto the next part of the conversation.
if (finished) break
}
// The conversation continues...
})
Reusable Patterns
We can compose patterns that abstract over the low-level back-and-forth of user-assistant messaging. Conversations become our units of composition; we no longer think solely in terms of messages.
For example, we can express a reusable iterative refinement conversation pattern as follows.
refine.ts
export const refine = (content: string) =>
Effect.gen(function*() {
// For 5 iterations.
for (let i = 0; i < 5; i++) {
// Append a message asking for the refinement.
yield* L.user`Refine the following text: ${content}`
// Infer and reassign `content` to an assistant message.
content = yield* L.assistant
}
// Return the final `content`.
return content
}).pipe(
// Denotes the boundary of the conversation.
// Conceptually similar to `Effect.scoped`.
L.thread,
)
We can then share and consume this pattern––or any Liminal effect––as we would any other TypeScript file or library.
refine_consumer.ts
import { refine } from "./refine.ts"
const maybeRefine = Effect.fn(function*(content: string) {
// Ask whether to utilize the pattern.
yield* L.user`Does the following text require refinement?: ${content}`
// Have the model answer our question.
const { needsRefinement } = yield* L.assistantSchema({
needsRefinement: Schema.Boolean,
})
// If so...
if (needsRefinement) {
// Refine and return.
return yield* refine(content)
}
// Otherwise, return the initial content.
return content
})
Branching
Liminal provides mechanisms for branching conversations so that we can easily explore alternative outcomes from any given state.
In the following example, the Rap
, Rock
and Pop
branches all inherit an isolated copy of the parent's messages, which contain the initial user message.
Effect.gen(function*() {
yield* L.user`Write a song about TypeScript in the genre of...`
const variants = yield* Effect.all(
["Rap", "Rock", "Pop"].map((genre) =>
L.thread(
L.user(genre),
L.assistant,
)
),
{ concurrency: "unbounded" },
)
variants satisfies Array<string>
})
In the next section we cover Liminal's installation and a basic example of its usage.