Skip to main content

A minimal, async-native, and unopinionated toolkit for modern LLM applications.

Project description

lingo library logo

A minimal, async-native, and unopinionated toolkit for modern LLM applications.

PyPI - Version PyPi - Python Version Github - Open Issues PyPi - Downloads (Monthly) Github - Commits


Lingo is a lightweight, type-safe Python framework for building LLM-powered applications. It moves beyond generic "agents" and "chains" to focus on Conversational Modeling—the discipline of defining exactly how a system perceives, processes, and advances a dialogue state.

It unifies three powerful paradigms in a single, typed architecture:

  1. Procedural Skills (Linear, script-like flows)
  2. Symbolic States (Deterministic FSMs)
  3. Reflexive Patterns (Event-driven guardrails)

⚡ Features

  • 💾 Stateful by Default: The Python stack is your state machine. Use await engine.ask() to pause execution and wait for user input naturally.
  • 🧠 Cognitive Architecture: Mix rigid business rules (States) with flexible reasoning (Skills).
  • 🛡️ Type-Safe: Built on Pydantic. All inputs, outputs, and tool calls are validated schemas.
  • 🌊 Low-Level Flow Control: Direct access to the underlying Flow graph for complex orchestration (Fork/Join, Retry, Loops).
  • 🔧 Native Tool-Calling (new in 2.0): Pass tools=[...] to LLM.chat() and lingo serializes the schemas, parses the model's tool calls back into Message.tool_calls, and surfaces thinking + stop_reason. The library wires the pipes — you keep control of the loop.

🆕 What's new in 2.0

  • Native tool-calling wireLLM.chat(messages, tools=[...]) now serializes @tool schemas to OpenAI's native tools=[...] API field and parses the streamed tool_calls back into Message.tool_calls (a list of ToolCall(id, name, arguments)). New streaming callbacks on_toolcall_start/on_toolcall_delta/on_toolcall_end mirror the existing on_token pattern for live UIs.
  • Finalized thinking on every message — reasoning fragments are now accumulated onto Message.thinking in addition to the existing on_reasoning_token stream.
  • Message.stop_reason — captures the OpenAI finish_reason (stop / length / tool_calls / content_filter) so consumers can distinguish "the model stopped naturally" from "the model wants to call a tool" from "max tokens hit".
  • ToolCall exported at the top levelfrom lingo import ToolCall.

Two paths coexist in 2.0, and you pick based on what you need:

  • Native tool-calling (LLM.chat(tools=...)Message.tool_calls) — the LLM decides when and which tools to call, in parallel batches if it wants. You execute and decide whether to loop. Fastest path; built for agent-style applications. See examples/native_tool_call.py.
  • Structured-output tool dispatch (Engine.equip/invoke/act) — the developer decides which tool gets called and asks the LLM only to fill its arguments via structured output. More controlled; lets you drive the conversation explicitly. See examples/banker.py.

🚀 Quickstart

Installation

pip install lingo-ai

The "Hello World" (Stateful Wizard)

Lingo allows you to model conversations as linear scripts. You don't need to manage session IDs or database steps manually—variables persist in memory across turns.

import asyncio
from lingo import Lingo

# Initialize the application
app = Lingo("Wizard", description="A helpful setup wizard")

@app.skill
async def onboarding(ctx, eng):
    # 1. Output a message
    await eng.reply(ctx, "Welcome to the system.")

    # 2. PAUSE execution and wait for user input
    # Lingo automatically suspends the stack here.
    # The variable 'name' is preserved in memory when the user replies!
    name = await eng.ask(ctx, "What is your name?")

    # 3. Resume and use context from previous turns
    email = await eng.ask(ctx, f"Hi {name}, what is your email?")

    # 4. Use structured decision making (LLM is forced to return bool)
    if await eng.decide(ctx, f"Is {email} a valid corporate email address?"):
        await eng.reply(ctx, "Registration complete.")
    else:
        await eng.reply(ctx, "Personal emails are not allowed.")

if __name__ == "__main__":
    from lingo.cli import loop
    loop(app)

💡 The runnable version of this quickstart lives at examples/wizard.py. It extends the pattern with engine.choose (LLM picks from a list), engine.create (LLM extracts a typed object), and engine.decide (LLM returns a bool) — the four conversational-modeling primitives.

📂 Examples

The examples/ directory has 10 runnable end-to-end demos:

  • hello_world.py — the minimal Lingo() + CLI loop
  • wizard.py — conversational-modeling primitives (ask / choose / decide / create)
  • banker.py — structured tool dispatch via engine.equip + engine.invoke
  • native_tool_call.py — 2.0 native tool-calling with a manual loop
  • native_tool_call_streaming.py — same with live streaming callbacks
  • fsm.pyStateMachine with per-state tools (triage → billing/tech)
  • smart_home.py — nested skills with scoped tools (kitchen, etc.)
  • state_rpg.py — persistent State (Pydantic) with state-mutating tools
  • when.py — reflexive @app.when guardrail patterns
  • injection.pydepends() + hidden underscore params + tool-from-tool

Every example is run end-to-end against MockLLM in CI (tests/test_examples.py) — they don't bitrot.

🧠 The Three Modeling Paradigms

Lingo gives you the right abstraction for every type of logic.

1. Symbolic States (Finite State Machine)

Best for: Business Logic, Security Boundaries, Multi-Step Workflows.

Use the StateMachine to enforce strict rules about allowed transitions.

from lingo.fsm import StateMachine

# 1. Initialize the FSM with the bot's registry
fsm = StateMachine(app.registry)

@fsm.state
async def login(ctx, eng):
    await eng.reply(ctx, "Please log in.")
    # Deterministic transition to the next state
    fsm.goto(dashboard, restart=True)

@fsm.state
async def dashboard(ctx, eng):
    await eng.reply(ctx, "Welcome to your dashboard.")
    # Logic restricted to this state...

# Register the FSM as a skill
@app.skill
async def run_workflow(ctx, eng):
    await fsm.execute(ctx, eng)

2. Reflexive Patterns (Event-Driven)

Best for: Guardrails, Interruptions, Global Commands.

Use @app.when to define high-priority listeners that intercept messages before they reach skills.

@app.when("User wants to quit or cancel the operation")
async def emergency_stop(ctx, eng):
    await eng.reply(ctx, "Stopping immediately.")
    eng.stop() # Terminates the flow and clears the stack

3. Structured Flows (Low-Level Graph)

Best for: Parallel Processing, Retries, Complex Orchestration.

You can drop down to the Flow API to build complex execution graphs explicitly.

from lingo import Flow

# Define two sub-flows
research = Flow("Research").reply("Searching for info...")
draft = Flow("Draft").reply("Drafting content...")

# Build a flow that runs them in parallel (Fork)
# and summarizes the result
complex_flow = (
    Flow("ParallelWorker")
    .fork(
        research,
        draft,
        aggregator="Combine the research and draft into a final report."
    )
)

🔧 Native Tool-Calling (new in 2.0)

For agent-style applications where the LLM decides which tools to call:

import asyncio
from lingo import LLM, Message, tool


@tool
async def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    return f"sunny, 22°C in {city}"


async def main():
    llm = LLM()  # picks up MODEL / BASE_URL / API_KEY from env
    messages = [Message.user("What's the weather in Havana?")]

    # Manual dispatch loop: keep calling until no more tool calls.
    while True:
        msg = await llm.chat(messages, tools=[get_weather])
        messages.append(msg)
        if not msg.tool_calls:
            print(msg.content)
            break
        for call in msg.tool_calls:
            result = await get_weather.run(**call.arguments)
            # tool_call_id is REQUIRED so the model can link the result
            # back to the call it made.
            messages.append(Message.tool(str(result), tool_call_id=call.id))


asyncio.run(main())

Lingo doesn't loop for you, doesn't execute tools, doesn't decide what's next. It wires the pipes — schema serialization out, tool-call parsing back, streaming events — and lets you build the agentic surface you want on top. For a complete agentic framework on top of lingo see apiad/lovelaice.

See examples/native_tool_call.py for the one-shot loop and examples/native_tool_call_streaming.py for the streaming callbacks (live token + tool-call rendering).

📦 Architecture

  • Context: Mutable ledger of the conversation history.
  • Engine: The "actuator" that drives the LLM. It exposes methods like .ask(), .decide(), .choose(), and .create().
  • Flow: The underlying graph representation of all skills.
  • LLM: One-call interface to the language model. Streams content/reasoning/tool-calls; returns a rich Message (content, tool_calls, thinking, stop_reason, usage).

🤝 Contribution

We welcome contributions! Please see CONTRIBUTING for details on how to set up your development environment and submit pull requests.

📄 License

This project is licensed under the MIT License - see the LICENSE file for details.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

lingo_ai-2.0.2.tar.gz (160.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

lingo_ai-2.0.2-py3-none-any.whl (37.9 kB view details)

Uploaded Python 3

File details

Details for the file lingo_ai-2.0.2.tar.gz.

File metadata

  • Download URL: lingo_ai-2.0.2.tar.gz
  • Upload date:
  • Size: 160.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.15 {"installer":{"name":"uv","version":"0.11.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for lingo_ai-2.0.2.tar.gz
Algorithm Hash digest
SHA256 54404fb6f598dc48050d445fc7f1104359398a409fe68083b866fae3b3b21989
MD5 0e49bbcdc01d27bf83d5c1d37fd57cf9
BLAKE2b-256 8a071ee9c93b6f4070b917c270b7492157260e5f4be4f67d19d06e7c2f877339

See more details on using hashes here.

File details

Details for the file lingo_ai-2.0.2-py3-none-any.whl.

File metadata

  • Download URL: lingo_ai-2.0.2-py3-none-any.whl
  • Upload date:
  • Size: 37.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.15 {"installer":{"name":"uv","version":"0.11.15","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for lingo_ai-2.0.2-py3-none-any.whl
Algorithm Hash digest
SHA256 a9707ab407a059faa05e0bbb810b0b7b1b939289441ca80534448488088989a3
MD5 8bd26f05f1aacc846f4684fbe344bbec
BLAKE2b-256 badf432d69f73dec5581529be80347d0bb902fa0d85ee34c0c76228e6100c439

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page