chore: import upstream snapshot with attribution
Verify and Test / build (3.10) (push) Waiting to run
Lint / build (3.10) (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:42:59 +08:00
commit 177604c7d1
780 changed files with 207718 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04
ARG DEBIAN_FRONTEND=noninteractive
ARG USER=vscode
RUN DEBIAN_FRONTEND=noninteractive \
&& apt-get update \
&& apt-get install -y build-essential --no-install-recommends make \
ca-certificates \
bash-completion \
git \
just \
libssl-dev \
zlib1g-dev \
libbz2-dev \
libreadline-dev \
libsqlite3-dev \
wget \
curl \
llvm \
libncurses5-dev \
xz-utils \
tk-dev \
libxml2-dev \
libxmlsec1-dev \
libffi-dev \
liblzma-dev
# Python and poetry installation
USER $USER
ARG HOME="/home/$USER"
ARG PYTHON_VERSION=3.10
ENV PYENV_ROOT="${HOME}/.pyenv"
ENV PATH="${PYENV_ROOT}/shims:${PYENV_ROOT}/bin:${HOME}/.local/bin:$PATH"
EXPOSE 8800
RUN echo "Configuring Python environment" \
&& curl https://pyenv.run | bash \
&& pyenv install ${PYTHON_VERSION} \
&& pyenv global ${PYTHON_VERSION} \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& pip install ruff semver toml
+52
View File
@@ -0,0 +1,52 @@
{
"name": "uv-pyenv",
"build": {
"dockerfile": "Dockerfile"
},
// "features": {},
// 👇 Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// 👇 Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "(cd /workspaces/parlant* && git config --global --add safe.directory $PWD && python ./scripts/initialize_repo.py)",
// 👇 Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"alexkrechik.cucumberautocomplete",
"charliermarsh.ruff",
"github.remotehub",
"github.vscode-github-actions",
"GitHub.vscode-pull-request-github",
"hbenl.vscode-test-explorer",
"matangover.mypy",
"ms-azuretools.vscode-docker",
"ms-python.debugpy",
"ms-python.python",
"ms-python.vscode-pylance",
"mutantdino.resourcemonitor",
"njpwerner.autodocstring",
"tamasfe.even-better-toml",
"streetsidesoftware.code-spell-checker"
]
}
},
// 👇 Features to add to the Dev Container. More info: https://containers.dev/implementors/features.
"features": {
"ghcr.io/devcontainers-extra/features/mypy:2": {
"version": "latest"
},
"node": {
"version": "lts",
"nodeGypDependencies": true
}
},
"mounts": [],
// 👇 Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
"containerEnv": {
"OPENAI_API_KEY": "${localEnv:OPENAI_API_KEY}"
},
"remoteEnv": {
"OPENAI_API_KEY": "${localEnv:OPENAI_API_KEY}"
}
}
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
ROOT=$(git rev-parse --show-toplevel)
cd $ROOT
python scripts/lint.py --ruff
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
ROOT=$(git rev-parse --show-toplevel)
cd $ROOT
python scripts/lint.py --mypy --ruff
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# A git commit hook that will automatically append a DCO signoff to the bottom
# of any commit message that does not have one. This append happens after the git
# default message is generated, but before the user is dropped into the commit
# message editor.
ROOT=$(git rev-parse --show-toplevel)
cd $ROOT
COMMIT_MESSAGE_FILE="$1"
AUTHOR=$(git var GIT_AUTHOR_IDENT)
SIGNOFF=$(echo "$AUTHOR" | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# Check for DCO signoff message. If one does not exist, append one and then warn
# the user that you did so.
if ! grep -qs "^$SIGNOFF" "$COMMIT_MESSAGE_FILE"; then
echo -e "\n$SIGNOFF" >> "$COMMIT_MESSAGE_FILE"
echo -e "Appended the following signoff to the end of the commit message:\n $SIGNOFF\n"
fi
+29
View File
@@ -0,0 +1,29 @@
---
name: Bug Report
about: Create a report to help us improve
title: "[Bug] <Replace this with a descriptive title>"
labels: bug
assignees: ''
---
# Description
A clear and concise description of what the bug is.
# How to Reproduce
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
# Expected Behavior
A clear and concise description of what you expected to happen.
# Environment
- OS: [e.g. iOS]
- Python version [e.g. 3.12]
- Parlant version [e.g. 1.5.1]
# Discussion
Add any other context or open questions about the problem here.
+17
View File
@@ -0,0 +1,17 @@
---
name: Feature Request
about: Suggest an idea for this project
title: "[Enhancement] <Replace this with a descriptive title>"
labels: enhancement
assignees: ''
---
# Motivation
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
# Solution Proposal
A clear and concise description of what you want to happen.
# Discussion
Add any other context or open questions about the feature request here.
+4
View File
@@ -0,0 +1,4 @@
allowRemediationCommits:
individual: true
require:
members: false
+65
View File
@@ -0,0 +1,65 @@
name: Verify and Test
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "develop" ]
jobs:
build:
runs-on: ubuntu-24.04
strategy:
matrix:
python-version: ["3.10"]
steps:
- name: checkout branch commit
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
run: pip install uv
- name: Initial Configs
run: |
git config --local core.hooksPath .githooks/
chmod +x .githooks/pre-commit .githooks/pre-push
- name: Install packages
run: python scripts/install_packages.py
- name: install just
uses: extractions/setup-just@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Test Parlant (deterministic)
if: always()
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: just test-deterministic
- name: Test Parlant (core-stable)
if: always()
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: just test-core-stable
- name: Test Parlant (core-unstable)
if: always()
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: just test-core-unstable
- name: test log artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: testresults
path: logs/*
+106
View File
@@ -0,0 +1,106 @@
name: Docker
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
schedule:
- cron: '44 23 * * *'
push:
branches: [ "develop" ]
# Publish semver tags as releases.
tags: [ 'v*.*.*' ]
pull_request:
branches: [ "develop" ]
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: emcie-co/parlant
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Install the cosign tool except on PR
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
with:
cosign-release: 'v2.2.4'
# Set up BuildKit Docker container builder to be able to build
# multi-platform images and export cache
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=edge
# Remove unused packages and directories to free up space for a (possibly large) Docker build
- name: Cleanup disk space
run: |
chmod +x ./scripts/ci/github_action_ubuntu_2404_free_space.sh
./scripts/ci/github_action_ubuntu_2404_free_space.sh
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with:
context: .
file: ./Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.event_name != 'pull_request' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
+41
View File
@@ -0,0 +1,41 @@
name: Lint
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-24.04
strategy:
matrix:
python-version: ["3.10"]
steps:
- name: checkout branch commit
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
run: pip install uv
- name: Initial Configs
run: |
git config --local core.hooksPath .githooks/
chmod +x .githooks/pre-commit .githooks/pre-push
- name: Install packages
run: python scripts/install_packages.py
continue-on-error: false
- name: Lint packages
run: python scripts/lint.py
continue-on-error: false
+25
View File
@@ -0,0 +1,25 @@
.env
.pytest_cache
.mypy_cache
.ruff_cache
__pycache__
.justfile
testresults**
tests/core/persistence/test_cache/*.json
data/*.json
cache/
.coverage
.DS_STORE
*~
.vscode
.venv
.cursor
logs
runtime-data
parlant-data
/dist/
scripts/sdks/
scripts/fern/openapi
fern.generate.log
schematic_generation_test_cache.json
test_timing.csv
+569
View File
@@ -0,0 +1,569 @@
# Changelog
All notable changes to Parlant will be documented here.
## [Unreleased]
### Added
- Add `HealthReporter` (`parlant.core.health_reporter`) — a generic, per-process health-reporting service registered in the container. Subsystems call `report(kind, attributes)`; registered `HealthView` objects interpret reports per kind and contribute to the `/healthz` snapshot. Each kind has a configurable retention policy (`window` + `max_count`). Views declare `Criticality.CRITICAL` or `INFORMATIONAL`; only critical views feed the worst-of overall status rollup
- Add `NLPHealthView` reporting NLP request health sliced by schema (success rate, p50/p95 latency, recent error breakdown) with configurable thresholds for `degraded`/`unhealthy` classification
- Instrument `BaseSchematicGenerator.generate()` and `BaseEmbedder.embed()` to emit `nlp.request` / `nlp.embed` health reports on success and failure, providing dashboard visibility into LLM and embedding behavior across all adapters
- Wrap the existing event-loop check as `EventLoopHealthView` so `/healthz` rollup is uniform across all health views
- Add per-decision debug logs to journey node selection (`Journey '<title>': advanced/stayed/exited/completed/backtracked/auto-advanced/...`) so journey progression is visible at debug level alongside guideline matching
- Add a warning log for invalid condition ids returned during journey next-step selection
### Changed
- Rename journey `conditions` to `triggers` throughout the codebase, REST API, CLI, and SDKs to better reflect their role as activation signals. The REST API field, query parameter, and request bodies use `triggers` (no aliases). The Python SDK `Server.create_journey(...)` keeps `conditions=` as a deprecated keyword that emits a `DeprecationWarning`; passing both `triggers=` and `conditions=` raises an error. Existing journey records are migrated automatically by `parlant-prepare-migration` from the `journey_conditions` collection (with a `condition` field) to a new `journey_triggers` collection (with a `trigger` field). LLM prompt strings that include "Journey activation condition" are intentionally preserved
- Rename SDK callback `on_match` to `on_selected` on guidelines and journey state transitions to reflect that it fires post-resolution, when the entity is selected for message generation; `EngineHooks.on_guideline_match_handlers` and `on_journey_match_handlers` are renamed to `on_guideline_selected_handlers` and `on_journey_selected_handlers` accordingly
- Standardize guideline matcher log vocabulary: `"Activated"``"Matched"`, `"Skipped"``"Not matched"`, and `"Not applied"``"Unapplied"`
- Standardize relational resolver log vocabulary: `"Skipped: ... deactivated due to ..."``"Dropped (<reason>): ..."` with reasons `lower priority`, `unmet dependency`, `dependency on dropped entity`, `deprioritized by guideline`, and `deprioritized by journey`
- Disambiguation batch now uses the standard matcher vocabulary (`"Matched (disambiguation)"` / `"Not matched (disambiguation)"`) and emits a log on the negative branch (previously silent)
- Normalize observational batch rationale to plain `match.rationale` (no longer wrapped with `Condition Application Rationale: "..."`) for consistency with other batches
- Normalize low-criticality batch warning string to `"No checks generated"` to match other batches
### Removed
- Remove redundant `glm_service.py` NLP adapter and `NLPServices.glm()` factory method — the GLM/bigmodel.cn API is already covered by the existing Zhipu adapter (`zhipu_service.py`), which uses the official `zhipuai` SDK and supports GLM-4 model variants. Use `NLPServices.zhipu()` instead.
### Fixed
- Fix low-criticality matcher logging the entire inference blob once per guideline in a batch (N copies of the same payload at debug level); now logs a single per-item entry
- Fix WebSocketLogger event loop starvation — when no WebSocket clients are subscribed, the drain loop processed queued messages without yielding, progressively blocking the async event loop and causing increasing latency over time
- Fix journey reachable-follow-ups evaluation being order-dependent at fan-in nodes — `JourneyReachableNodesEvaluator` captured a child's path list by reference and then prepended to it in place, so a node with multiple parents had its stored routes mutated by whichever parent was visited first; the second parent then lost routes (or double-counted a hop) depending purely on graph/DFS order. The child's routes are now snapshotted per parent, making the result depend only on journey structure. This intentionally changes the computed follow-ups for existing fan-in journeys: a shared child's later parent now retains the routes it previously lost
### Security
- Upgrade dependencies to address known CVEs: authlib (>=1.6.11), requests (>=2.33.0), fastmcp (>=3.2.0), litellm (>=1.83.0), pytest (>=9.0.3), pyjwt (>=2.11.1), and constrain transitive deps — aiohttp, cryptography, pillow, pyopenssl, werkzeug, Mako, pyasn1, python-multipart, orjson, Pygments, diskcache
- Upgrade chat frontend: vite (>=7.3.2) and override transitive deps — picomatch, lodash, flatted, brace-expansion, immutable, yaml
## [3.3.1] - 2026-04-14
### Added
- Allow passing ToolId when attaching tools throughout the SDK
- Add `AnyOf(tag)` and `AllOf(tag)` modifiers for explicit control over tag dependency semantics in `depend_on()``AnyOf` requires at least one tagged member to be active, `AllOf` requires all of them (bare `Tag` defaults to `AllOf`)
- Add `depend_on_any()` to `Guideline`, `Tag`, and `Journey` for OR dependency relationships — at least one target must be active. Multiple `depend_on_any()` calls create independent OR groups that are AND'd together
- Add event loop health monitoring to `/healthz` endpoint — measures callback latency and reports `healthy`, `degraded`, or `unhealthy` status with peak latency over a configurable window
- Add resolution tracking to the relational resolver — every entity that enters resolution gets a `Resolution` with a `ResolutionKind` (`NONE`, `DEPRIORITIZED`, `UNMET_DEPENDENCY_ALL`, `UNMET_DEPENDENCY_ANY`, `ENTAILED`) and structured `ResolutionDetails` (relationship ID, target IDs) explaining why
### Changed
- Split `RelationshipEntityKind.TAG` into `TAG_ALL` and `TAG_ANY` to support explicit tag dependency semantics at the core level (existing `TAG` entries treated as `TAG_ALL` for backwards compatibility)
### Fixed
- Fix priority and dependency relationships propagating through inactive intermediaries — only direct relationships now affect resolution, consistent with the reinstatement principle from argumentation theory
- Fix entailment recording only the highest-scoring source when multiple guidelines entail the same target — all entailing relationships are now recorded in resolution details
- Fix dep-failed guidelines not recovering when entailment satisfies their dependency target in a later iteration
- Fix SDK startup appearing stuck at 100% after evaluations — add "Applying evaluations" progress bar for the metadata-writing phase
- Fix `Variable.get_value()` returning `None` when called from a retriever, caused by retrievers starting before context variables were loaded
- Fix journey tool-state auto-advancing even when the tool did not run
## [3.3.0] - 2026-03-15
### Added
- Add per-agent planners via `Server.create_agent(planner=...)`, allowing each agent to use a custom `Planner` implementation
- Accept `Tag` as a target in `depend_on()`, `exclude()`, and `prioritize_over()` on both `Guideline` and `Tag`, enabling relationships that target all guidelines sharing a custom tag
- Add `Tag.depend_on()`, `Tag.exclude()`, and `Tag.prioritize_over()` methods to the SDK, enabling tag-based dependency and priority relationships with guidelines and journeys
- Support custom TAG as source for DEPENDENCY relationships in the relational resolver
- Add `tags` parameter to `create_guideline`, `create_observation`, and `create_journey` on both `Agent` and `Journey`, allowing custom tags to be attached to entities at creation time
- Add `Tag.reevaluate_after()` method to the SDK, enabling tag-based reevaluation relationships with tools
- Add tag-based reevaluation support in the engine: when a tool fires, all guidelines carrying a tag that has a reevaluation relationship with that tool are now re-evaluated
- Add staged_events to GuidelineMatchingContext in SDK
- Add `priority` property to guidelines and journeys for priority-based filtering in the relational resolver
- Add transient guidelines (renamed from tool-provided guidelines), allowing tools to dynamically inject behavioral guidelines into the agent's context
- Add `Agent.utter()` to the SDK, enabling programmatic agent message generation with transient guidelines
- Add `Customer.update()` and `CustomerMetadata` to the SDK, allowing tools to update customer name and metadata
- Add `Session.update()`, `SessionMetadata`, and `SessionLabels` to the SDK, allowing tools to update session properties, metadata, and labels
- Add `customer`, `agent`, `mode`, and `title` properties to SDK `Session` class
- Add `Server.get_tag()` to the SDK, supporting lookup by either `id` or `name`
- Add name-based filtering to `TagStore.list_tags()` and the `GET /tags` API endpoint via an optional `name` query parameter
- Enforce tag name uniqueness in `TagStore`, raising an error when creating a tag with a duplicate name
### Changed
- Made extended thinking indicator optional in perceived performance policy
- Change `reevaluate_after()` on `Tag` and `Guideline` to accept multiple tools (`*tools`) and return `Sequence[Relationship]`
- Change `tags` field type from `Sequence[TagId]` to `Sequence[Tag]` on `Guideline`, `Journey`, `Capability`, `Term`, `Variable`, `Customer`, and `Agent` in the SDK
- Change `Tag.preamble()` to return a full `Tag` object instead of a `TagId`
- Upgrade MCP service and bump dependency versions to resolve security vulnerabilities
### Deprecated
- OpenAPI tool services are now deprecated; please migrate to SDK tool services
### Fixed
- Fix deadlock when sending a new message right after a preamble
- Fix transitive filtering in relational resolver for custom tag dependency targets (guidelines depending on a custom tag are now correctly deactivated when a tagged member is deprioritized)
- Fix SSE `read_event` endpoint stalling after first streaming chunk until full completion
- Fix response analysis logs not always reaching the integrated UI
- Fix guideline formatting in canned response and streaming modes when condition is absent
- Fix AzureService small text embedding dimension size
- Fix onnxruntime compatibility with Python 3.10 and transformers 5.x type changes
- Fix agent intention proposer prompt clarification
- Fix embedding LRU cache eviction corrupting the length index when entries share the same text length
- Fix LiteLLMEmbedder failing to resolve via lagom container when LITELLM_EMBEDDING_MODEL_NAME is set
- Fix non-consequential tool calls being rejected when optional parameters are missing
### Removed
- Remove stale `parlant-test` entry point and testing framework documentation from README
## [3.2.2] - 2026-02-18
### Added
- Added p.MATCH_ALWAYS, now the preferred alias to p.Guideline.MATCH_ALWAYS
- Added `logger` property to p.Server
### Changed
- Adjusted log levels of relational resolver to trace instead of debug
- Allow tool context parameter names to be all of 'context', 'ctx', and 'c'
### Fixed
- Fix completed streamed messages re-animating on page refresh
- Propagate `Server.current` context to tool functions in hosted plugin server
## [3.2.1] - 2026-02-17
### Added
- Add optional `dependencies` parameter to guideline, observation, and journey creation methods
- Add `exclude()` as an alias for `prioritize_over()` on guidelines and journeys
- Add `tools` parameter to `create_observation` methods
### Changed
- Deprecate `attach_tool()` in favor of `create_guideline()`/`create_observation()` with `tools` parameter
### Fixed
- Preserve draft message language during canned response recomposition
- Fix server hang when an exception occurs during setup
- Fix canned response field extraction to handle falsy values
## [3.2.0] - 2026-02-08
### Added
- Add labels to Guidelines, Journeys, JourneyNodes, and Sessions for categorization and filtering
- Add automatic session label propagation from matched entities (guidelines, observations, journeys)
- Add `track` parameter to guidelines to control "previously applied" tracking
- Support multiple targets in `prioritize_over()` and `depend_on()` methods
- Add `field_dependencies` to canned responses for explicit field availability requirements
- Add `attach_retriever()` to Guideline, Journey, and JourneyState for conditional data retrieval
- Add `on_match` and `on_message` hooks to journeys for lifecycle callbacks
- Add per-agent preamble configuration (custom examples and instructions)
- Add separate default greeting responses for first agent message in fluid mode
- Add streaming message output mode
- Allow specifying custom journey node ID
- Add matched guidelines/journey states to completion ready event
### Changed
- Make condition optional for SDK guidelines
- Tweak default preamble examples
- Soften log levels for relational guideline resolver
- Add activated/skipped logs to custom guideline matcher batches
### Fixed
- Fix websocket warning upon startup
- Fix agent intention proposer (guidelines were getting rewritten incorrectly)
- Fix multiple customer guideline matchers not working
- Fix bug with context variable access in SDK
## [3.1.0] - 2026-01-05
### Added
- Add .current property for Server, Agent, and Customer in SDK
- Add /healthz endpoint
- Add API for CRUD operations on session metadata
- Add EmcieService
- Add GLM service
- Add Mistral service
- Add OpenRouter service
- Add OpenTelemetry integration for Meter, Logger and Tracer
- Add Qdrant VectorDatabase adapter
- Add Snowflake Cortex service
- Add ability to configure and extend the FastAPI app object
- Add deferred retrievers
- Add dynamic composition mode
- Add follow-up canned responses
- Add guideline criticality level
- Add guideline on_match() hooks
- Add persistence option for context variable values (variable store)
- Added guideline descriptions
- Allow bailing out of canned response selection and utilize the draft directly, using a hook
- Allow controlling max tool result payload via environment variable
- Allow controlling perceived performance policy per agent
- Allow journey transitions from one tool state to another
- Allow specifying custom IDs when creating agents via SDK and API
- Allow specifying custom IDs when creating customers via SDK and API
- Allow specifying custom IDs when creating guidelines, journeys, and glossary terms via SDK and API
- Expose IoC container in server object
- Support adding custom canrep fields to matched guidelines and journey states
- Support code-based, custom guideline matchers
### Changed
- Changed default NLPService to EmcieService
- Improved efficiency of journey state matching when first state is a tool state
- Rename ContextualCorrelator to Tracer
- Rename LoadedContext to EngineContext
- Support proxy URL for LiteLLM
### Fixed
- Fix critical bug with cancellation during response analysis
- Fix critical similarity calculation error in TransientVectorDatabase
- Fix unnecessary extra evaluation of journeys and tools in some edge cases
- Improved Gemini Flash 2.5 output consistency by using function call trick instead of structured outputs
## [3.0.4] - 2025-11-18
### Fixed
- Fix bug where NanoDB query failed when no filters matched
- Extend tool insights across iterations
- Fix deprecated status.HTTP_422_UNPROCESSABLE_ENTITY to status.HTTP_422_UNPROCESSABLE_CONTENT
- Fix broken CLI by adding missing websocket-client dependency
- Added specific classes for embedder initialisation
- Make base url once in OllamaEmbedder
- Update dependencies for security, upgrade FastAPI, fix mypy in hugging_face.py
- Bump torch for fixing vulnerability
## [3.0.3] - 2025-10-23
### Fixed
- Fix installation issue in some environments, failing due to an older FastMCP version
- Bump versions of OpenTelemetry
- Made ChromaDB an extra package parlant[chroma]
- Update NPM dependencies for integrated UI
## [3.0.2] - 2025-08-27
### Added
- Added docs/\* and llms.txt
- Added Vertex NLP service
- Added Ollama NLP service
- Added LiteLLM support to the SDK
- Added Gemini support to the SDK
- Added Journey.create_observation() helper
- Added auth permission READ_AGENT_DESCRIPTION
- Added optional AWS_SESSION_TOKEN to BedrockService
- Support creating status events via the API
### Changed
- Moved tool call success log to DEBUG level
- Optimized canrep to not generate a draft in strict mode if no canrep candidates found
- Removed `acknowledged_event_offset` from status events
- Removed `last_known_event_offset` from `LoadedContext.interaction`
### Fixed
- Fixed presentation of missing API keys for built-in NLP services
- Improvements to canned response generation
- Fixed bug with null journey paths in some cases
- Fixed tiny bug with terminal nodes in journey node selection
- Fixed evaluations not showing properly after version upgrade
## [3.0.1] - 2025-08-16
### Changed
- Move tool call success log to DEBUG level
### Fixed
- Fix tool-based variable not enabling the associated tool on the server
- Fix authorization errors throwing 500 instead of 403
- Changed OpenAI LLM request operation level to TRACE to fix evaluation progress bars
## [3.0.0] - 2025-08-15
- Please see the announcement at https://parlant.io/blog/parlant-3-0-release
## [2.2.0] - 2025-05-20
### Added
- Add journeys
- Add of guideline properties evaluation
- Add automatic guideline action deduction when adding direct tool guidelines
- Added choices of invalid and missing tool parameters to tool insights
### Changed
- Make guideline action optional
## [2.1.2] - 2025-05-07
### Changed
- Remove interaction history from utterance recomposition prompt
- Use tool calls from the entire interaction for utterance field substitution
- Improve error handling and reporting with utterance rendering failures
### Fixed
- Always reason about utterance selection to improve performance
## [2.1.1] - 2025-04-30
### Fixed
- Fixed rendering relationships in CLI
- Fixed parlant client using old imports from python client SDK
## [2.1.0] - 2025-04-29
### Added
- ToolParameterOptions.choice_provider can now access ToolContext
- Added utterance/draft toggle in the integrated UI
- Added new guideline relationship: Dependency
- Added tool relationships and the OVERLAP relationship
- Added the 'overlap' property to tools. By default, tools will be assumed not to overlap with each other, simplifying their evaluation at runtime.
- Introduce ToolBatchers
- Introduce Journey
### Changed
- Improved tool calling efficiency by adjusting the prompt to the tool at hand
- Revised completion schema (ARQs) for tool calling
- Utterances now follow a 2-stage process: draft + select
- Changed guest customer name to Guest
### Fixed
- Fixed deprioritized guidelines always being skipped
- Fixed agent creation with tags
- Fixed client CLI exit status when encountering an error
- Fixed agent update
### Known Issues
- OpenAPI tool services sometimes run into issues due to a version update in aiopenapi3
## [2.0.0] - 2025-04-09
### Added
- Improved tool parameter flexibility: custom types, Pydantic models, and annotated ToolParameterOptions
- Allow returning a new (modified) container in modules using configure_module()
- Added Tool Insights with tool parameter options
- Added support for default values for tool parameters in tool calling
- Added WebSocket logger feature for streaming logs in real time
- Added a log viewer to the sandbox UI
- Added API and CLI for Utterances
- Added support for the --migrate CLI flag to enable seamless store version upgrades during server startup
- Added clear rate limit error logs for NLP adapters
- Added enabled/disabled flag for guidelines to facilitate experimentation without deletion
- Allow different schematic generators to adjust incoming prompts in a structured manner
- Added tags to context variables, guidelines, glossary and agents
- Added guideline matching strategies
- Added guideline relationships
- Added support for tool parameters choice provider using the tool context as argument
### Changed
- Made the message generator slightly more polite by default, following user feedback
- Allow only specifying guideline condition or action when updating guideline from CLI
- Renamed guideline proposer with guideline matcher
### Fixed
- Lowered likelihood of the agent hallucinating facts in fluid mode
- Lowered likelihood of the agent offering services that were not specifically mentioned by the business
## [1.6.2] - 2025-01-29
### Fixed
- Fix loading DeepSeek service during server boot
## [1.6.1] - 2025-01-20
### Fixed
- Fix ToolCaller not getting clear information on a parameter being optional
- Ensure ToolCaller only calls a tool if all required args were given
- Improve valid JSON generation likelihood in MessageEventGenerator
- Improve ToolCaller's ability to correctly run multiple tools at once
## [1.6.0] - 2025-01-19
### Added
- Add shot creation helper functions under Shot
- Add ContextEvaluation in MessageEventGenerator
- Add a log command under client CLI for streaming logs
- Add engine lifecycle hooks
### Changed
- Split vendor dependencies to extra packages to avoid reduce installation time
- Modified ToolCaller shot schema
- Disable coherence and connection checking by default in the CLI for now
### Fixed
- Improved GuidelineProposer's ability to handle compound actions
- Improved GuidelineProposer's ability to distinguish between a fulfilled and unfulfilled action
- Improved GuidelineProposer's ability to detect a previously applied guideline's application to new information
- Reduced likelihood of agent offering hallucinated services
- Fix ToolCaller false-negative argument validation from int to float
- Fix ToolCaller accuracy
- Fix ToolCaller making up argument values when it doesn't have them
- Fix some cases where the ToolCaller also calls a less-fitting tool
- Fix mistake in coherence checker few shots
- Fix markdown tables in sandbox UI
- Fix wrong import of RateLimitError
- Fix PluginServer validation for optional tool arguments when they're passed None
- Fix utterances sometimes not producing a message
## [1.5.1] - 2025-01-05
### Fixed
- Fix server CLI boot
## [1.5.1] - 2025-01-05
### Fixed
- Fix server CLI boot
## [1.5.0] - 2025-01-04
### Added
- Add DeepSeek provider support (via DeepSeekService)
### Changed
- Change default home dir from runtime-data to parlant-data
### Fixed
- Fix tool-calling test
- Fix HuggingFace model loading issues
## [1.4.3] - 2025-01-02
### Fixed
- Upgraded dependency "tiktoken" to 0.8.0 to fix installation errors on some environments
## [1.4.2] - 2024-12-31
### Fixed
- Fix race condition in JSONFileDocumentDatabase when deleting or updating documents
## [1.4.1] - 2024-12-31
### Changed
- Remove tool metadata from prompts - agents are now only aware of the data itself
### Fixed
- Fix tool calling in scenarios where a guideline has multiple tools where more than one should run
## [1.4.0] - 2024-12-31
### Added
- Support custom plugin data for PluginServer
- Allow specifying custom logger ID when creating loggers
- Add 'hosted' parameter to PluginServer, for running inside modules
### Fixed
- Fix the tool caller's few shots to include better rationales and arguments.
## [1.3.1] - 2024-12-27
### Changed
- Return event ID instead of trace ID from utterance API
- Improve and normalize entity update messages in client CLI
## [1.3.0] - 2024-12-26
### Added
- Add manual utterance requests
- Refactor few-shot examples and allow adding more examples from a module
- Allow tapping into the PluginServer FastAPI app to provide additional custom endpoints
- Support for union parameters ("T | None") in tool functions
### Changed
- Made all stores thread-safe with reader/writer locks
- Reverted GPT version for guideline connection proposer to 2024-08-06
- Changed definition of causal connection to take the source's when statement into account. The connection proposer now assumes the source's condition is true when examining if it entails other guideline.
### Fixed
- Fix 404 not being returned if a tool service isn't found
- Fix having direct calls to asyncio.gather() instead of safe_gather()
### Removed
- Removed connection kind (entails / suggests) from the guideline connection proposer and all places downstream. the connection_kind argument is no longer needed or supported for all guideline connections.
## [1.2.0] - 2024-12-19
### Added
- Expose deletion flag for events in Session API
### Changed
- Print traceback when reporting server boot errors
- Make cancelled operations issue a warning rather than an error
### Fixed
- Fixed tool calling with optional parameters
- Fixed sandbox UI issues with message regeneration and status icon
- Fixed case where guideline is applied due to condition being partially applied
### Removed
None
## [1.1.0] - 2024-12-18
### Added
- Customer selection in sandbox Chat UI
- Support tool calls with freshness rules for context variables
- Add support for loading external modules for changing engine behavior programmatically
- CachedSchematicGenerator to run the test suite more quickly
- TransientVectorDatabase to run the test suite more quickly
### Changed
- Changed model path for Chroma documents. You may need to delete your `runtime-data` dir.
### Fixed
- Improve handling of partially fulfilled guidelines
### Removed
None
+40
View File
@@ -0,0 +1,40 @@
This is the main repo of Parlant (https://parlant.io).
Parlant is a Python based agent framework. Its core strengths:
1. It allows you to create compliant and controlled AI agents for customer-facing use cases
2. It provides many conversational management features out of the box
3. It's built for enterprise, large-scale use cases, where SLAs, stability and security are paramount
The repo's structure follows the Hexagonal Architecture (Ports and Adapters) approach.
- src/parlant
- core: Core framework code
- adapters: Implementations of interfaces using 3rd party tools
- api: REST API layer using FastAPI. Uses modules from core/
- tests: all tests for the project. Structure strives to mirror that which is under src/parlant.
General Coding Instructions:
- Always ensure you stick to Hexagonal Architecture patterns in line with how they're used in this codebase.
- Every time you add something, look for similar things in the codebase and ensure you follow the coding style.
- We use MyPy on strict mode. Every parameter needs to be type-annotated. Every function's result too.
- If you need to add a test for something, first say where you plan to add it and ask for confirmation.
- We follow TDD. When you make a change, first create a failing test. Once it fails, implement just enough so it passes.
- If you need to test classes/methods in sdk.py (or generally to test things that relate to engine behavior) make sure you inherit from SDKTest and understand how it works and how to use it.
- Test names should go "test*that*..." using clear names that explain the context, what is executed, and what is the expected result.
- You can run tests using pytest. Make sure you run "uv run pytest tests/path/to/test/file.py" while also specifying the test name that you need to run.
Always follow this plan when asked to code a feature or fix a bug:
1. Consider the codebase's structure
2. Describe your implementation plan, including:
a. What tests you will write (test names + files they would live in)
b. Why do you think the tests would initially fail
c. Where you would plan to implement the code that would make the tests pass
3. Ask for plan confirmation. If you get feedback, revise your plan and ask for confirmation again until you get it.
4. Implement the tests first. Ask for confirmation and code review.
5. Once tests are approved, once again suggest your implementation plan for making them pass, and get plan review until confirmation.
6. Once your implementation plan is confirmed, go ahead with implementing the code to pass them.
7. Make sure to format all of the files you changed using ruff (it is installed in the environment).
8. Run `uv run python scripts/lint.py --mypy --ruff` to ensure your code has no lint issues.
+28
View File
@@ -0,0 +1,28 @@
# DCO Sign Off
All commits must be signed off with the Developer Certificate of Origin ([DCO.md](DCO.md)).
This attests that you have the rights to submit your contribution under our project's license (Apache 2.0).
To sign off your commits:
1. Configure your Git client with your github account details:
```
git config --global user.name "Your Name"
git config --global user.email "your.email@example.com"
```
2. If you've configured git to use our hooks (`.githooks`), you are now ready. Otherwise, either:
1. use our `.githooks`:
```
git config set core.hookspath .githooks
```
**OR**
2. Add the `-s` flag when committing:
```
git commit -s -m "Your commit message"
```
### Or
* Add the sign-off manually with:
```
Signed-off-by: Your Name <your.email@example.com>
```
+34
View File
@@ -0,0 +1,34 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Emcie Co Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+436
View File
@@ -0,0 +1,436 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/emcie-co/parlant/blob/develop/docs/LogoTransparentLight.png?raw=true">
<img alt="Parlant" src="https://github.com/emcie-co/parlant/blob/develop/docs/LogoTransparentDark.png?raw=true" width=400 />
</picture>
### The interaction control harness for customer-facing AI agents
<p>
<a href="https://pypi.org/project/parlant/"><img alt="PyPI" src="https://img.shields.io/pypi/v/parlant?color=blue"></a>
<img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10+-blue">
<a href="https://opensource.org/licenses/Apache-2.0"><img alt="License" src="https://img.shields.io/badge/license-Apache%202.0-green"></a>
<a href="https://discord.gg/duxWqxKk6J"><img alt="Discord" src="https://img.shields.io/discord/1312378700993663007?color=7289da&logo=discord&logoColor=white"></a>
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/emcie-co/parlant?style=social">
</p>
<p>
<a href="https://www.parlant.io/" target="_blank">Website</a> &bull;
<a href="https://www.parlant.io/docs/quickstart/installation" target="_blank">Quick Start</a> &bull;
<a href="https://www.parlant.io/docs/quickstart/examples" target="_blank">Examples</a> &bull;
<a href="https://discord.gg/duxWqxKk6J" target="_blank">Discord</a>
</p>
<p>
<a href="https://zdoc.app/de/emcie-co/parlant">Deutsch</a> |
<a href="https://zdoc.app/es/emcie-co/parlant">Español</a> |
<a href="https://zdoc.app/fr/emcie-co/parlant">français</a> |
<a href="https://zdoc.app/ja/emcie-co/parlant">日本語</a> |
<a href="https://zdoc.app/ko/emcie-co/parlant">한국어</a> |
<a href="https://zdoc.app/pt/emcie-co/parlant">Português</a> |
<a href="https://zdoc.app/ru/emcie-co/parlant">Русский</a> |
<a href="https://zdoc.app/zh/emcie-co/parlant">中文</a>
</p>
<a href="https://trendshift.io/repositories/12768" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/12768" alt="Trending" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
</div>
&nbsp;
> **Looking for an open-source alternative to Ada, Decagon, or Sierra?**
**Parlant is production-ready. It streamlines the development and maintenance of enterprise-grade B2C (business-to-consumer) and sensitive B2B interactions that need to be consistent, compliant, on-brand, and comprehensively traceable.**
## Why Parlant?
Conversational context engineering is hard because real-world interactions are diverse, nuanced, and non-linear.
### ❌ The Problem: What you've probably tried and couldn't get to work at scale
**System prompts** work until production complexity kicks in. The more instructions you add to a prompt, the faster your agent stops paying attention to any of them.
**Routed graphs** solve the prompt-overload problem, but the more routing you add, the more fragile it becomes when faced with the chaos of natural interactions.
### 🔑 The Solution: Context engineering, optimized for conversational control
Parlant is an agentic harness offering optimized [context engineering](https://www.gartner.com/en/articles/context-engineering) for conversational use cases: getting the right context, no more and no less, into the prompt at the right time. You define rules, knowledge, and tools once, while the engine narrows the context down in real-time to what's immediately relevant to each turn of the conversation.
<img alt="Parlant Demo" src="https://github.com/emcie-co/parlant/blob/develop/docs/demo.gif?raw=true" width="100%" />
### How is Parlant different from LangGraph or DSPy?
Parlant focuses on conversational governance and behavioral control and consistency, while LangGraph is ideal for workflow automation, and DSPy is ideal for low-level prompt optimization.
## Design goals
Parlant is built around three goals that shape every decision in the framework:
### 1. Maximum control over the conversation experience
Parlant was designed around a simple idea: developers should be able to control the agent's behavior with precision. In customer-facing conversations, small details matter, like tone, timing, edge cases, policy constraints, and brand voice. So we chose a design that makes these aspects easily configurable and manageable. That approach adds complexity, but it gives teams tighter control over how the agent behaves in real conversations.
### 2. Maximum prevention of unwanted behaviors
Parlant treats misalignment as a core design problem. It builds on [research into model accuracy and consistency](https://arxiv.org/abs/2503.03669#:~:text=We%20present%20Attentive%20Reasoning%20Queries%20%28ARQs%29%2C%20a%20novel,in%20Large%20Language%20Models%20through%20domain-specialized%20reasoning%20blueprints.) so that it is structurally harder for the agent to behave outside its intended boundaries, and easier to detect and correct when it does. Rather than bolting guardrails onto the output, Parlant applies constraints and control points into how your LLMs are used in the first place to produce safe and correct output.
### 3. Fastest path from product feedback to implementation
Parlant seeks to allow those responsible for the agent's conversational experience to shape its behavior in an intuitive manner, enabling a rapid feedback cycle that engineers can accomodate. Parlant is designed to allow you to incorporate ongoing product feedback as quickly as possible, without manual rewiring of graphs or fine-tuning of models, ensuring that valuable engineering time is only needed for deeper changes, not minor adjustments.
## Getting started
```bash
pip install parlant
```
```python
import parlant.sdk as p
async with p.Server():
agent = await server.create_agent(
name="Customer Support",
description="Handles customer inquiries for an airline",
)
# Evaluate and call tools only under the right conditions
expert_customer = await agent.create_observation(
condition="customer uses financial terminology like DTI or amortization",
tools=[research_deep_answer],
)
# When the expert observation holds, always respond
# with depth. Set the guideline to automatically match
# whenever the observation it depends on holds...
expert_answers = await agent.create_guideline(
matcher=p.MATCH_ALWAYS,
action="respond with technical depth",
dependencies=[expert_customer],
)
beginner_answers = await agent.create_guideline(
condition="customer seems new to the topic",
action="simplify and use concrete examples",
)
# When both match, beginners wins. Neither expert-level
# tool-data nor instructions can enter the agent's context.
await beginner_answers.exclude(expert_customer)
```
Follow the **[5-minute quickstart](https://www.parlant.io/docs/quickstart/installation)** for a full walkthrough.
## Parlant at a glance
You define your agent's behavior in code (not prompts), and the engine dynamically narrows the context on each turn to only what's immediately relevant, so the LLM stays focused and your agent stays aligned.
```mermaid
graph TD
O[Observations] -->|Events| E[Contextual Matching Engine]
G[Guidelines] -->|Instructions| E
J["Journeys (SOPs)"] -->|Current Steps| E
R[Retrievers] -->|Domain Knowledge| E
GL[Glossary] -->|Domain Terms| E
V[Variables] -->|Memories| E
E -->|Tool Requests| T[Tool Caller]
T -.->|Results + Optional Extra Matching Iterations| E
T -->|**Key Result:**<br/>Focused Context Window| M[Message Generation]
```
Instead of sending a large system prompt followed by a raw conversation to the model, Parlant first assembles a focused context — matching only the instructions and tools relevant to each conversational turn — then generates a response from that narrowed context.
```mermaid
%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#e8f5e9', 'primaryTextColor': '#1b5e20', 'primaryBorderColor': '#81c784', 'lineColor': '#66bb6a', 'secondaryColor': '#fff9e1', 'tertiaryColor': 'transparent'}}}%%
flowchart LR
A(User):::outputNode
subgraph Engine["Parlant Engine"]
direction LR
B["Match Guidelines and Resolve Journey States"]:::matchNode
C["Call Contextually-Associated Tools and Workflows"]:::toolNode
D["Generated Message"]:::composeNode
E["Canned Message"]:::cannedNode
end
A a@-->|💬 User Input| B
B b@--> C
C c@-->|Fluid Output Mode?| D
C d@-->|Strict Output Mode?| E
D e@-->|💬 Fluid Output| A
E f@-->|💬 Canned Output| A
a@{animate: true}
b@{animate: true}
c@{animate: true}
d@{animate: true}
e@{animate: true}
f@{animate: true}
linkStyle 2 stroke-width:2px
linkStyle 4 stroke-width:2px
linkStyle 3 stroke-width:2px,stroke:#3949AB
linkStyle 5 stroke-width:2px,stroke:#3949AB
classDef composeNode fill:#F9E9CB,stroke:#AB8139,stroke-width:2px,color:#7E5E1A,stroke-width:0
classDef cannedNode fill:#DFE3F9,stroke:#3949AB,stroke-width:2px,color:#1a237e,stroke-width:0
```
In this way, adding more rules makes the agent smarter, not more confused — because the engine filters context relevance, not the LLM.
## Is Parlant for you?
Parlant is built for teams that need their AI agent to behave reliably in front of real customers. It's a good fit if:
- You're building a **customer-facing agent** — support, sales, onboarding, advisory — where tone, accuracy, and compliance matter.
- You have **dozens or hundreds of behavioral rules** and your system prompt is buckling under the weight.
- You're in a **regulated or high-stakes domain** (finance, insurance, healthcare, telecom) where every response needs to be explainable and auditable.
**_Parlant is deployed in production at the most stringent organizations, including banks._**
> _Parlant isn't just a framework. It's a high-level software that solves the conversational modeling problem head-on._
> — **Sarthak Dalabehera**, Principal Engineer, Slice Bank
> _By far the most elegant conversational AI framework that I've come across._
> — **Vishal Ahuja**, Senior Lead, Applied AI, JPMorgan Chase
> _Parlant dramatically reduces the need for prompt engineering and complex flow control. Building agents becomes closer to domain modeling._
> — **Diogo Santiago**, AI Engineer, Orcale
## Features
- **[Guidelines](https://parlant.io/docs/concepts/customization/guidelines)** —
Behavioral rules as condition-action pairs; the engine matches only what's relevant per turn.
- **[Relationships](https://parlant.io/docs/concepts/customization/relationships)** —
Dependencies and exclusions between guidelines to keep the context narrow and focused.
- **[Journeys](https://parlant.io/docs/concepts/customization/journeys)** —
Multi-turn SOPs that adapt to how the customer actually interacts.
- **[Canned Responses](https://parlant.io/docs/concepts/customization/canned-responses)** —
Pre-approved response templates that eliminate hallucination at critical moments.
- **[Tools](https://parlant.io/docs/concepts/customization/tools)** —
External APIs and workflows, triggered only when their observation matches.
- **[Glossary](https://parlant.io/docs/concepts/customization/glossary)** —
Domain-specific vocabulary so the agent understands customer language.
- **[Explainability](https://parlant.io/docs/advanced/explainability)** —
Full OpenTelemetry tracing — every guideline match and decision is logged.
## [Guidelines](https://parlant.io/docs/concepts/customization/guidelines)
Behavioral rules as condition-action pairs: when the condition applies, the action kicks into context.
Instead of cramming all guidelines in a single prompt, the engine evaluates which ones apply on each conversational turn and only includes the relevant ones in the LLM's context.
This lets you define hundreds of guidelines without degrading adherence.
```python
await agent.create_guideline(
condition="customer uses financial terminology like DTI or amortization",
action="respond with technical depth — skip basic explanations",
)
```
## [Relationships](https://parlant.io/docs/concepts/customization/guidelines)
Relationships between elements help you keep the final context just right: narrow and focused.
**Exclusion** relationships keep certain guidelines out of the model's attention when conflicting ones are matched.
```python
for_experts = await agent.create_guideline(
condition="customer uses financial terminology",
action="respond with technical depth",
)
for_beginners = await agent.create_guideline(
condition="customer seems new to the topic",
action="simplify and use concrete examples",
)
# In conflicting reads of the customer, set which takes priority
await for_beginners.exclude(for_experts)
```
**Dependency** relationships ensure a guideline only activates when another one has set the stage, helping you create _topic-based guideline hierarchies._
```python
suspects_fraud = await agent.create_observation(
condition="customer suspects unauthorized transactions on their card",
)
await agent.create_guideline(
condition="customer wants to take action regarding the transaction",
action="ask whether they want to dispute the transaction or lock the card",
# Only activates when fraud suspicion has been established
dependencies=[suspects_fraud],
)
```
## [Journeys](https://parlant.io/docs/concepts/customization/journeys)
Multi-turn SOPs (Standard Operating Procedures). Define a flow for processes like booking, troubleshooting, or onboarding. The agent follows the flow but adapts — it can fast-forward states, revisit earlier ones, or adjust pace based on how the customer interacts.
```python
journey = await agent.create_journey(
title="Book Flight",
description="Guide the customer through flight booking",
conditions=["customer wants to book a flight"],
)
t0 = await journey.initial_state.transition_to(
# Instruction to follow while in this state (could be multiple turns)
chat_state="See if they're interested in last-minute deals",
)
# Branch A - not interested in deals
t1 = await t0.target.transition_to(
chat_state="Determine where they want to go and when",
condition="They aren't interested",
)
# Branch B - interested in deals
t2 = await t0.target.transition_to(
tool_state=load_latest_flight_deals,
condition="They are",
)
t3 = await t1.target.transition_to(
chat_state="List deals and see if they're interested",
)
```
## [Canned Responses](https://parlant.io/docs/concepts/customization/canned-responses)
At critical moments or conversational events, limit the agent to using only pre-approved response templates.
After running the matching sequence and drafting a message to the customer, the agent selects the template that best matches its generated draft instead of sending it directly, eliminating hallucination risk entirely and keeping wording exact to the letter.
```python
await agent.create_guideline(
condition="The customer discusses things unrelated to our business"
action="Tell them you can't help with that",
# Strict composition mode triggers when this guideline
# matches - the rest of the agent stays fluid
composition_mode=p.CompositionMode.STRICT,
canned_responses=[
await agent.create_canned_response(
"Sorry, but I can't help you with that."
)
],
priority=100, # Top priority, focuses the agent on this alone
)
```
## [Tools](https://parlant.io/docs/concepts/customization/tools)
Tools activate only when their observation matches; they don't sit in the context permanently. This prevents the false-positive invocations that plague traditional LLM tool setups.
```python
@p.tool
async def query_docs(context: p.ToolContext, user_query: str) -> p.ToolResult:
results = search_knowledge_base(user_query)
return p.ToolResult(results)
await agent.create_observation(
condition="customer asks about service features",
tools=[query_docs],
)
```
Tools can also feed custom values into canned response templates.
## [Glossary](https://parlant.io/docs/concepts/customization/glossary)
Domain-specific vocabulary for your agent. Map colloquial terms and synonyms to precise business definitions so the agent understands customer language.
```python
await agent.create_term(
name="Ocean View",
description="Room category with direct view of the Atlantic",
synonyms=["sea view", "rooms with a view to the Atlantic"],
)
```
## [Explainability](https://parlant.io/docs/advanced/explainability)
Every decision is traced with OpenTelemetry. Parlant ships out of the box with elaborate logs, metrics, and traces.
## Framework Integration
Parlant handles conversational governance; it doesn't replace your existing stack.
Use it alongside frameworks like LangGraph, Agno, LlamaIndex, or others for workflow automation and knowledge retrieval. Parlant takes over the behavioral control layer while your framework of choice handles the rest of your agent's processing logic.
Any external workflow or agent becomes a Parlant tool, triggered only when relevant:
```python
from my_workflows import refund_graph # a compiled LangGraph StateGraph
@p.tool
async def run_refund_workflow(
context: p.ToolContext,
order_id: str
) -> p.ToolResult:
result = await refund_graph.ainvoke({"order_id": order_id})
# Graph result can inject both data and instructions into the agent.
# Instructions are transformed to guidelines, and participate
# in contextual guideline resolution (including prioritizations)
return p.ToolResult(
data=result["data"],
# Inject dynamic guidelines from workflow result
guidelines=[
{"action": inst, "priority": 3} for inst in result["instructions"]
],
)
await agent.create_observation(
condition="customer wants to process a refund",
tools=[run_refund_workflow],
)
```
The same pattern works with LlamaIndex query engines, Agno agents, or any async Python function.
## LLM Agnostic
Parlant works with most LLM providers. The recommended ones are [Emcie](https://www.emcie.co) which delivers an ideal cost/quality value since it's built specifically for Parlant, but OpenAI and Anthropic deliver excellent quality outputs as well. You can also use any model and provider via LiteLLM, but they need to be good ones - off-the-shelf models which are too small tend to produce inconsistent results.
Generally, you can swap models without changing behavioral configuration.
## [Official React Chat Widget](https://github.com/emcie-co/parlant-chat-react)
Drop-in chat component to get a frontend running immediately.
## Learn more
- **[How Parlant ensures compliance](https://www.parlant.io/blog/how-parlant-guarantees-compliance)** — deep dive into the engine
- **[Parlant vs LangGraph](https://www.parlant.io/blog/parlant-vs-langgraph)** — when to use which
- **[Parlant vs DSPy](https://www.parlant.io/blog/parlant-vs-dspy)** — different tools for different problems
## Community - Get Help with Parlant
- **[Discord](https://discord.gg/duxWqxKk6J)** — ask questions, share what you're building
- **[GitHub Issues](https://github.com/emcie-co/parlant/issues)** — bug reports and feature requests
- **[Contact](https://parlant.io/contact)** — reach the engineering team directly
**If Parlant helps you build better agents, **[give it a star](https://github.com/emcie-co/parlant)** — it helps others find the project.**
## License
Apache 2.0 — free for commercial use.
---
<div align="center">
**[Try it now](https://www.parlant.io/docs/quickstart/installation)** &bull; **[Join Discord](https://discord.gg/duxWqxKk6J)** &bull; **[Read the docs](https://www.parlant.io/)**
Built by the team at **[Emcie](https://emcie.co)**
</div>
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`emcie-co/parlant`
- 原始仓库:https://github.com/emcie-co/parlant
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+297
View File
@@ -0,0 +1,297 @@
# Azure OpenAI Service Documentation
The Azure service provides integration with Azure OpenAI services, supporting both legacy API key authentication and modern Azure AD authentication. This integration enables Parlant to leverage Azure's enterprise-grade AI services while maintaining security best practices.
## Prerequisites
1. **Azure OpenAI Resource**: Create an Azure OpenAI resource in your Azure subscription
2. **Authentication Setup**: Choose between API key or Azure AD authentication
3. **Model Deployment**: Deploy required models in your Azure OpenAI resource
4. **Permissions**: Ensure proper IAM roles for Azure AD authentication
## Authentication Methods
### Development (Local Machine)
For local development, use Azure CLI authentication:
```bash
# Install Azure CLI if not already installed
# https://docs.microsoft.com/en-us/cli/azure/install-azure-cli
# Login to Azure
az login
# Set your endpoint
export AZURE_ENDPOINT="https://your-resource.openai.azure.com/"
```
### Production (Server Deployment)
For server deployment, **do NOT use `az login`**. Instead, use one of these methods:
#### Option 1: Service Principal (Recommended)
```bash
# Set environment variables
export AZURE_ENDPOINT="https://your-resource.openai.azure.com/"
export AZURE_CLIENT_ID="your-service-principal-client-id"
export AZURE_CLIENT_SECRET="your-service-principal-secret"
export AZURE_TENANT_ID="your-azure-tenant-id"
```
#### Option 2: Managed Identity (Azure Resources)
If running on Azure VMs, App Services, or other Azure resources:
```bash
# Only set the endpoint - authentication is automatic
export AZURE_ENDPOINT="https://your-resource.openai.azure.com/"
```
#### Option 3: Workload Identity (Kubernetes)
For Kubernetes deployments:
```bash
export AZURE_ENDPOINT="https://your-resource.openai.azure.com/"
export AZURE_CLIENT_ID="your-workload-identity-client-id"
export AZURE_TENANT_ID="your-azure-tenant-id"
export AZURE_FEDERATED_TOKEN_FILE="/var/run/secrets/azure/tokens/azure-identity-token"
```
## Environment Variables
### Required Variables
- `AZURE_ENDPOINT`: Your Azure OpenAI resource endpoint
### Optional Variables
- `AZURE_API_VERSION`: API version (default: "2024-08-01-preview")
- `AZURE_GENERATIVE_MODEL_NAME`: Model name (default: "gpt-4o")
- `AZURE_GENERATIVE_MODEL_WINDOW`: Context window size (default: 4096)
- `AZURE_EMBEDDING_MODEL_NAME`: Embedding model (default: "text-embedding-3-large")
- `AZURE_EMBEDDING_MODEL_DIMS`: Embedding dimensions (default: 3072)
- `AZURE_EMBEDDING_MODEL_WINDOW`: Embedding context window (default: 8192)
## Supported Models
The Azure service supports **any Azure OpenAI model** that is deployed and available in your Azure OpenAI resource. The models listed below are pre-configured defaults, but you can use any model by setting the appropriate environment variables.
### Pre-configured Generative Models
| Model Name | Description | Context Window | Use Case |
|------------|-------------|---------------|----------|
| `gpt-4o` | Most capable GPT-4 model (default) | 128K tokens | Complex reasoning, high accuracy |
| `gpt-4o-mini` | Faster, cost-effective GPT-4 | 128K tokens | Balanced performance and cost |
### Pre-configured Embedding Models
| Model Name | Dimensions | Context Window | Description |
|------------|------------|---------------|-------------|
| `text-embedding-3-large` | 3072 | 8192 | High-quality embeddings (default) |
| `text-embedding-3-small` | 3072 | 8192 | Efficient embeddings |
### Using Custom Models
You can use **any Azure OpenAI model** that is deployed in your Azure OpenAI resource:
```bash
# Use any generative model (examples - check your Azure resource for availability)
export AZURE_GENERATIVE_MODEL_NAME="gpt-35-turbo" # GPT-3.5 Turbo
export AZURE_GENERATIVE_MODEL_NAME="gpt-4" # GPT-4
export AZURE_GENERATIVE_MODEL_NAME="gpt-4-turbo" # GPT-4 Turbo
# Use any embedding model (examples - check your Azure resource for availability)
export AZURE_EMBEDDING_MODEL_NAME="text-embedding-ada-002" # Ada embeddings
export AZURE_EMBEDDING_MODEL_NAME="text-embedding-3-large" # Large embeddings
```
**Important**:
- Model availability depends on what you've deployed in your Azure OpenAI resource
- Not all models are available in all Azure regions
- Check your Azure OpenAI resource deployment to see which models are available
## Authentication Priority
The service follows this authentication priority:
1. **API Key** (highest priority - for backward compatibility)
2. **Azure AD** (fallback when no API key is present)
## Required Azure Permissions
For Azure AD authentication, ensure your identity has the following role on the Azure OpenAI resource:
- **Cognitive Services OpenAI User**: Required for accessing Azure OpenAI services
## Usage Example
```python
import parlant.sdk as p
from parlant.sdk import NLPServices
async with p.Server(nlp_service=NLPServices.azure) as server:
agent = await server.create_agent(
name="Healthcare Agent",
description="Is empathetic and calming to the patient.",
)
```
## Server Deployment Guide
### Setting Up Service Principal for Production
1. **Create Service Principal**:
```bash
# Login as admin user
az login
# Create service principal
az ad sp create-for-rbac --name "parlant-service-principal" --role "Cognitive Services OpenAI User" --scopes "/subscriptions/YOUR_SUBSCRIPTION_ID/resourceGroups/YOUR_RESOURCE_GROUP/providers/Microsoft.CognitiveServices/accounts/YOUR_OPENAI_RESOURCE"
```
2. **Configure Environment Variables**:
```bash
export AZURE_ENDPOINT="https://your-resource.openai.azure.com/"
export AZURE_CLIENT_ID="appId-from-step-1"
export AZURE_CLIENT_SECRET="password-from-step-1"
export AZURE_TENANT_ID="tenant-from-step-1"
```
3. **Test Authentication**:
```bash
# Verify the service principal can access Azure OpenAI
python -c "
from parlant.adapters.nlp.azure_service import AzureService
error = AzureService.verify_environment()
print('Configuration OK' if error is None else f'Error: {error}')
"
```
### Configuration Tips
### Development Setup
```bash
export AZURE_ENDPOINT="https://my-resource.openai.azure.com/"
export AZURE_API_KEY="your-api-key"
export AZURE_GENERATIVE_MODEL_NAME="gpt-4o-mini"
```
### Production Setup (Azure AD)
```bash
export AZURE_ENDPOINT="https://my-resource.openai.azure.com/"
export AZURE_CLIENT_ID="your-client-id"
export AZURE_CLIENT_SECRET="your-client-secret"
export AZURE_TENANT_ID="your-tenant-id"
export AZURE_GENERATIVE_MODEL_NAME="gpt-4o"
```
## Troubleshooting
### Common Issues
1. **Authentication Failures**
```
Azure authentication is not properly configured.
```
**Solution**:
- For development: Run `az login` (only for local development)
- For production: Use service principal variables (NOT `az login`)
- Ensure "Cognitive Services OpenAI User" role is assigned
- Verify service principal has correct permissions
2. **Rate Limit Errors**
```
Azure API rate limit exceeded
```
**Solution**:
- Check Azure account balance and billing status
- Review API usage limits in Azure dashboard
- Consider upgrading service tier
3. **Model Access Denied**
```
Model not found or access denied
```
**Solution**:
- Verify model is deployed in your Azure OpenAI resource
- Check regional availability
- Ensure proper permissions
4. **Connection Errors**
```
Cannot connect to Azure OpenAI endpoint
```
**Solution**:
- Verify `AZURE_ENDPOINT` is correct
- Check network connectivity
- Ensure firewall allows Azure OpenAI traffic
## Available Model Classes
The service provides these pre-configured model classes for convenience, but supports any Azure OpenAI model:
### Pre-configured Classes
- `GPT_4o`: Most capable GPT-4 model (128K context) - **Default**
- `GPT_4o_Mini`: Faster, cost-effective GPT-4 (128K context)
- `AzureTextEmbedding3Large`: High-quality embeddings (3072 dimensions) - **Default**
- `AzureTextEmbedding3Small`: Efficient embeddings (3072 dimensions)
### Custom Model Classes
- `CustomAzureSchematicGenerator`: Uses any generative model via `AZURE_GENERATIVE_MODEL_NAME`
- `CustomAzureEmbedder`: Uses any embedding model via `AZURE_EMBEDDING_MODEL_NAME`
**The service automatically chooses the appropriate class based on your environment variables.**
### How Model Selection Works
The service uses this logic to select the appropriate model class:
```python
# Generative Model Selection
if AZURE_GENERATIVE_MODEL_NAME is set:
use CustomAzureSchematicGenerator # Any model you specify
else:
use GPT_4o # Default model
# Embedding Model Selection
if AZURE_EMBEDDING_MODEL_NAME is set:
use CustomAzureEmbedder # Any embedding model you specify
else:
use AzureTextEmbedding3Large # Default embedding model
```
This means you can use **any Azure OpenAI model** without code changes - just set the environment variables!
### Example: Using Different Models
```bash
# Use GPT-3.5 Turbo (if available in your region)
export AZURE_ENDPOINT="https://your-resource.openai.azure.com/"
export AZURE_GENERATIVE_MODEL_NAME="gpt-35-turbo"
export AZURE_EMBEDDING_MODEL_NAME="text-embedding-ada-002"
# Use GPT-4 Turbo (if available in your region)
export AZURE_GENERATIVE_MODEL_NAME="gpt-4-turbo"
export AZURE_EMBEDDING_MODEL_NAME="text-embedding-3-large"
# Use default models (GPT-4o and text-embedding-3-large)
export AZURE_ENDPOINT="https://your-resource.openai.azure.com/"
# No need to set AZURE_GENERATIVE_MODEL_NAME or AZURE_EMBEDDING_MODEL_NAME
```
## Security Notes
- **API Keys**: Store securely, rotate regularly
- **Azure AD**: Use managed identities in production
- **Network**: Ensure proper network security groups
- **Monitoring**: Monitor usage and access patterns
- **Compliance**: Follow organizational security policies
## Migration Guide
### From API Key to Azure AD
1. Set up Azure AD authentication using one of the supported methods
2. Remove the API key from your environment variables
3. Verify permissions - ensure your identity has "Cognitive Services OpenAI User" role
4. Test the configuration using `AzureService.verify_environment()`
### Backward Compatibility
The service maintains full backward compatibility:
- Existing API key configurations continue to work
- No changes required for existing deployments
- Gradual migration to Azure AD is supported
+193
View File
@@ -0,0 +1,193 @@
# Ollama Service Documentation
The Ollama service provides local LLM capabilities for Parlant using [Ollama](https://ollama.ai/). This service supports both text generation and embeddings using various open-source models.
## Prerequisites
1. **Install Ollama**: Download and install from [ollama.ai](https://ollama.ai/)
2. **Start Ollama server**: Run `ollama serve` (usually starts automatically)
3. **Pull required models** (see [Recommended Models](#recommended-models) section)
## Environment Variables
Configure the Ollama service using these environment variables:
```bash
# Ollama server URL (default: http://localhost:11434)
export OLLAMA_BASE_URL="http://localhost:11434"
# Model size to use (default: 4b)
# Options: gemma3:1b, gemma3:4b, llama3.1:8b, gemma3:12b, gemma3:27b, llama3.1:70b, llama3.1:405b
export OLLAMA_MODEL="gemma3:4b"
# Embedding model (default: nomic-embed-text)
# Options: nomic-embed-text, mxbai-embed-large
export OLLAMA_EMBEDDING_MODEL="nomic-embed-text"
# API timeout in seconds (default: 300)
export OLLAMA_API_TIMEOUT="300"
```
### Example Configuration
```bash
# For development (fast, good balance)
export OLLAMA_MODEL="gemma3:4b"
export OLLAMA_EMBEDDING_MODEL="nomic-embed-text"
export OLLAMA_API_TIMEOUT="180"
# higher accuracy cloud
export OLLAMA_MODEL="gemma3:4b"
export OLLAMA_EMBEDDING_MODEL="nomic-embed-text"
export OLLAMA_API_TIMEOUT="600"
```
## Recommended Models
**⚠️ IMPORTANT**: Pull these models before running Parlant to avoid API timeouts during first use:
### Text Generation Models
```bash
# Recommended for most use cases (good balance of speed/accuracy)
ollama pull gemma3:4b-it-qat
# Fast but may struggle with complex schemas
ollama pull gemma3:1b
# embedding model required for creating embeddings
ollama pull nomic-embed-text
```
### Large Models (Cloud/High-end Hardware Only)
```bash
# Better reasoning capabilities
ollama pull llama3.1:8b
# High accuracy for complex tasks
ollama pull gemma3:12b
# Very high accuracy (requires more resources)
ollama pull gemma3:27b-it-qat
# ⚠️ WARNING: Requires 40GB+ GPU memory
ollama pull llama3.1:70b
# ⚠️ WARNING: Requires 200GB+ GPU memory (cloud-only)
ollama pull llama3.1:405b
```
### Embedding Models
To use custom embedding model set OLLAMA_EMBEDDING_MODEL environment value as required name
Note that this implementation is tested using nomic-embed-text
**⚠️ IMPORTANT**:
Support for using other embedding models has been added including a custom embedding model of your own choice
Ensure to set OLLAMA_EMBEDDING_VECTOR_SIZE which is compatible with your own embedding model before starting the server
Tested with `snowflake-arctic-embed` with vector size of 1024
It is not NECESSARY to put OLLAMA_EMBEDDING_VECTOR_SIZE if you are using the supported `nomic-embed-text`, `mxbai-embed-large` or `bge-m3`. The vector size defaults to 768, 1024 and 1024 respectively for these
```bash
# Alternative embedding model (512 dimensions)
ollama pull mxbai-embed-large:latest
```
## Model Recommendations by Use Case
| Model Size | Use Case | Memory Requirements | Performance |
|------------|----------|-------------------|-------------|
| `1b` | Quick testing, simple tasks | ~2GB | Fast but limited accuracy |
| `4b` | **Recommended for development** | ~4GB | Good balance of speed/accuracy |
| `8b` | complex reasoning | ~8GB | Better reasoning than Gemma |
| `12b` | High-accuracy tasks | ~12GB | High accuracy, slower |
| `27b` | Complex workloads | ~27GB | Very high accuracy |
| `70b` | Enterprise/cloud only | ~40GB+ | Excellent accuracy |
| `405b` | Research/cloud only | ~200GB+ | State-of-the-art |
## Usage Example
```python
import parlant.sdk as p
from parlant.sdk import NLPServices
async with p.Server(nlp_service=NLPServices.ollama) as server:
agent = await server.create_agent(
name="Healthcare Agent",
description="Is empathetic and calming to the patient.",
)
```
## Configuration Tips
### Development Setup
```bash
export OLLAMA_MODEL=gemma3:4b
export OLLAMA_API_TIMEOUT=180
```
### High-Performance Setup (Cloud)
```bash
export OLLAMA_MODEL=llama3.1:70b
export OLLAMA_API_TIMEOUT=300
```
### Custom / Other models
```bash
export OLLAMA_MODEL=llama3.2:3b
export OLLAMA_API_TIMEOUT=300
```
## Troubleshooting
### Common Issues
1. **Model Not Found Error**
```
Model gemma3:4b not found. Please pull it first with: ollama pull gemma3:4b
```
**Solution**: Run `ollama pull gemma3:4b-it-qat` before starting Parlant
2. **Connection Error**
```
Cannot connect to Ollama server at http://localhost:11434
```
**Solution**: Ensure Ollama is running with `ollama serve`
3. **Timeout Error**
```
Request timed out after 300s
```
**Solution**: Increase `OLLAMA_API_TIMEOUT` or use a smaller model
4. **Out of Memory**
```
CUDA out of memory
```
**Solution**: Use a smaller model size or increase GPU memory
### Performance Optimization
1. **Pre-pull models**: Always pull models before first use
2. **Adjust timeout**: Increase timeout for larger models
3. **Model selection**: Use smallest model that meets accuracy requirements
4. **GPU memory**: Monitor GPU usage and adjust model size accordingly
## Available Model Classes
The service provides these pre-configured model classes:
- `OllamaGemma3_1B`: Fast, basic accuracy
- `OllamaGemma3_4B`: **Recommended** - good balance
- `OllamaLlama31_8B`: Better reasoning
- `OllamaGemma3_12B`: High accuracy
- `OllamaGemma3_27B`: Very high accuracy
- `OllamaLlama31_70B`: Enterprise-grade (high memory)
- `OllamaLlama31_405B`: Research-grade (very high memory)
## Security Notes
- Ollama runs locally, so no data leaves your machine
- No API keys required
- Models are downloaded and cached locally
- Consider firewall rules if exposing Ollama server externally
+554
View File
@@ -0,0 +1,554 @@
# OpenRouter Service Documentation
The OpenRouter service provides access to **400+ AI models** through a single unified API, including GPT-4, Claude, Llama, Qwen, and many more. OpenRouter makes it easy to switch between different models for both text generation and embeddings without changing code.
## Prerequisites
1. **OpenRouter Account**: Sign up at [openrouter.ai](https://openrouter.ai)
2. **API Key**: Get your API key from the [OpenRouter dashboard](https://openrouter.ai/keys)
3. **Model Access**: Ensure you have access to the models you want to use
## Quick Start
### Basic Setup
```bash
# Set your OpenRouter API key (required)
export OPENROUTER_API_KEY="your-api-key-here"
# Optionally set default models
export OPENROUTER_MODEL="openai/gpt-4o-mini"
export OPENROUTER_EMBEDDER_MODEL="openai/text-embedding-3-large"
```
### Minimal Example
```python
import parlant.sdk as p
from parlant.sdk import NLPServices
async with p.Server(nlp_service=NLPServices.openrouter) as server:
agent = await server.create_agent(
name="AI Assistant",
description="A helpful assistant powered by OpenRouter.",
)
# 🎉 Ready to use at http://localhost:8800
```
## Configuration
All configuration is done via environment variables. Set the required and optional environment variables before running your application:
```bash
# Required: API Key
export OPENROUTER_API_KEY="your-api-key-here"
# Optional: LLM Configuration
export OPENROUTER_MODEL="openai/gpt-4o-mini"
export OPENROUTER_MAX_TOKENS="128000"
# Optional: Embedding Configuration
export OPENROUTER_EMBEDDER_MODEL="qwen/qwen3-embedding-8b"
export OPENROUTER_EMBEDDER_DIMENSIONS="4096" # Optional override
# Optional: Analytics
export OPENROUTER_HTTP_REFERER="https://myapp.com"
export OPENROUTER_SITE_NAME="My Application"
```
## Environment Variables Reference
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `OPENROUTER_API_KEY` | Your OpenRouter API key | `sk-or-v1-...` |
### Optional Variables - LLM Configuration
| Variable | Description | Default | Example |
|----------|-------------|---------|---------|
| `OPENROUTER_MODEL` | LLM model name | `openai/gpt-4o` | `openai/gpt-4o-mini` |
| `OPENROUTER_MAX_TOKENS` | Max tokens limit | Auto-detected | `128000` |
### Optional Variables - Embedding Configuration
| Variable | Description | Default | Example |
|----------|-------------|---------|---------|
| `OPENROUTER_EMBEDDER_MODEL` | Embedding model name | `openai/text-embedding-3-large` | `qwen/qwen3-embedding-8b` |
| `OPENROUTER_EMBEDDER_DIMENSIONS` | Override embedding dimensions | Auto-detected | `4096` |
### Optional Variables - Analytics
| Variable | Description | Example |
|----------|-------------|---------|
| `OPENROUTER_HTTP_REFERER` | Your app's URL (for analytics) | `https://myapp.com` |
| `OPENROUTER_SITE_NAME` | Your app's name (for analytics) | `My Application` |
## Supported Models
OpenRouter supports **400+ models** from different providers. Models are automatically optimized with specialized configurations when available.
### Pre-configured LLM Models
These models have specialized configurations for optimal performance:
| Model | Provider | Context | Use Case |
|-------|----------|---------|----------|
| `openai/gpt-4o` | OpenAI | 128K | Default, best overall quality |
| `openai/gpt-4o-mini` | OpenAI | 128K | Cost-effective, fast |
| `anthropic/claude-3.5-sonnet` | Anthropic | 200K | Advanced reasoning, long context |
| `meta-llama/llama-3.3-70b-instruct` | Meta | 8K | Open-source option |
### Supported Embedding Models
The service supports multiple embedding models with automatic dimension detection:
| Model | Dimensions | Provider | Use Case |
|-------|------------|----------|----------|
| `openai/text-embedding-3-large` | 3072 | OpenAI | Default, high quality |
| `openai/text-embedding-3-small` | 1536 | OpenAI | Faster, smaller |
| `openai/text-embedding-ada-002` | 1536 | OpenAI | Legacy model |
| `qwen/qwen3-embedding-8b` | 4096 | Qwen | High dimension, multilingual |
| `qwen/qwen-embedding-v2` | 1536 | Qwen | Multilingual embeddings |
### Using Any OpenRouter Model
You can use **any model** that OpenRouter supports by setting the appropriate environment variables:
```bash
# LLM Models
export OPENROUTER_MODEL="google/gemini-pro-1.5"
# Embedding Models
export OPENROUTER_EMBEDDER_MODEL="qwen/qwen3-embedding-8b"
```
Check the [OpenRouter Models page](https://openrouter.ai/models) for the full list of available models.
## Usage Examples
### Example 1: Default Configuration
Use the default models (GPT-4o for LLM, text-embedding-3-large for embeddings):
```python
import parlant.sdk as p
from parlant.sdk import NLPServices
async with p.Server(nlp_service=NLPServices.openrouter) as server:
agent = await server.create_agent(
name="General Assistant",
description="A helpful AI assistant."
)
```
### Example 2: Custom LLM Model
Use Claude for text generation:
```bash
export OPENROUTER_MODEL="anthropic/claude-3.5-sonnet"
```
```python
async with p.Server(nlp_service=NLPServices.openrouter) as server:
agent = await server.create_agent(
name="Claude Assistant",
description="Powered by Claude."
)
```
### Example 3: Custom Embedder Model
Use a custom embedding model for better multilingual support:
```bash
export OPENROUTER_MODEL="openai/gpt-4o-mini"
export OPENROUTER_EMBEDDER_MODEL="qwen/qwen3-embedding-8b"
```
```python
async with p.Server(nlp_service=NLPServices.openrouter) as server:
agent = await server.create_agent(
name="Multilingual Assistant",
description="Supports multiple languages."
)
```
### Example 4: High-Performance Setup
Optimize for speed and quality:
```bash
export OPENROUTER_MODEL="openai/gpt-4o-mini"
export OPENROUTER_EMBEDDER_MODEL="openai/text-embedding-3-large"
export OPENROUTER_MAX_TOKENS="128000"
```
```python
async with p.Server(nlp_service=NLPServices.openrouter) as server:
agent = await server.create_agent(
name="High-Performance Agent",
description="Optimized for speed and accuracy."
)
```
### Example 5: Cost-Optimized Setup
Balance quality and cost:
```bash
export OPENROUTER_MODEL="openai/gpt-4o-mini"
export OPENROUTER_EMBEDDER_MODEL="openai/text-embedding-3-small"
```
```python
async with p.Server(nlp_service=NLPServices.openrouter) as server:
agent = await server.create_agent(
name="Cost-Optimized Agent",
description="Optimized for cost-effectiveness."
)
```
## Embedding Model Configuration
### Understanding Embedding Dimensions
Different embedding models produce vectors of different dimensions. The service automatically detects dimensions for known models, and can auto-detect from API responses for unknown models.
### Known Embedding Dimensions
The following models have pre-configured dimensions:
- `openai/text-embedding-3-large`: **3072** dimensions
- `openai/text-embedding-3-small`: **1536** dimensions
- `openai/text-embedding-ada-002`: **1536** dimensions
- `qwen/qwen3-embedding-8b`: **4096** dimensions
- `qwen/qwen-embedding-v2`: **1536** dimensions
### Auto-Detection
For unknown models, dimensions are automatically detected from the first API response and cached for subsequent use.
### Manual Dimension Override
If needed, you can manually specify dimensions via environment variable:
```bash
export OPENROUTER_EMBEDDER_DIMENSIONS="4096"
```
⚠️ **Important**: If you change embedder models or dimensions, you may need to clear your vector database cache to avoid dimension mismatch errors.
## Dynamic Model Selection
OpenRouter intelligently handles model selection and configuration:
### Automatic Generator Selection
Known models use specialized generators for optimal performance:
- `openai/gpt-4o``OpenRouterGPT4O`
- `openai/gpt-4o-mini``OpenRouterGPT4OMini`
- `anthropic/claude-3.5-sonnet``OpenRouterClaude35Sonnet`
- `meta-llama/llama-3.3-70b-instruct``OpenRouterLlama33_70B`
- Other models → Dynamic generator with auto-configured parameters
### Automatic Embedder Selection
Embedders are automatically configured based on the model name:
- Known models → Pre-configured dimensions
- Unknown models → Auto-detected dimensions from API response
- Dynamic embedder → Created with proper container resolution
## Advantages of OpenRouter
1. **Model Diversity**: Access to 400+ models from different providers
2. **Unified Embeddings**: Native support for embedding models via the same API
3. **Cost Flexibility**: Choose models based on price-performance needs
4. **Single API**: One integration for multiple providers
5. **Auto-Optimization**: Automatic configuration for known models
6. **Environment-Based Configuration**: All configuration via environment variables
7. **Analytics**: Built-in usage tracking through OpenRouter dashboard
## Troubleshooting
### Rate Limit Errors
**Error:**
```
OpenRouter API rate limit exceeded
```
**Solutions:**
- Check your OpenRouter account balance and billing status
- Review usage limits in the [OpenRouter dashboard](https://openrouter.ai/keys)
- Consider upgrading your plan for higher limits
- Try a different model with higher rate limits
- Wait a moment before retrying
### JSON Mode Not Supported
**Error:**
```
Model 'xyz' does not support JSON mode
```
**Solutions:**
- OpenRouter automatically falls back to prompting for JSON output
- Consider using a model that supports JSON mode:
- `openai/gpt-4o`
- `openai/gpt-4o-mini`
- `anthropic/claude-3.5-sonnet`
- The fallback still produces structured output reliably
### Dimension Mismatch Errors
**Error:**
```
ValueError: all the input array dimensions except for the concatenation axis must match exactly
```
**Solutions:**
- This occurs when switching embedder models with different dimensions
- Clear your vector database cache/embeddings
- Or delete the cached embeddings files in your `parlant-data` directory
- The embedder will create new embeddings with the correct dimensions
### Authentication Errors
**Error:**
```
OPENROUTER_API_KEY is not set
```
**Solutions:**
- Set the `OPENROUTER_API_KEY` environment variable
- Verify your API key in the [OpenRouter dashboard](https://openrouter.ai/keys)
- Ensure the key hasn't expired or been revoked
- Check for typos in the environment variable name
### Container Resolution Errors
**Error:**
```
Unable to construct dependency of type OpenRouterEmbedder
```
**Solutions:**
- This is automatically handled by the dynamic embedder class
- Ensure you're using the latest version of the code
- If the error persists, check that `embedder_model_name` is correctly set
## Cost Management
OpenRouter provides transparent pricing across models. Choose models based on your needs:
### Cost-Effective LLM Options
```python
# GPT-4o-mini - Good quality, lower cost
model_name="openai/gpt-4o-mini"
# Claude Haiku - Fast, affordable
model_name="anthropic/claude-3-haiku"
# Llama - Open source, very affordable
model_name="meta-llama/llama-3.3-70b-instruct"
```
### Cost-Effective Embedding Options
```python
# text-embedding-3-small - Smaller, faster, cheaper
embedder_model_name="openai/text-embedding-3-small"
# text-embedding-ada-002 - Legacy, very affordable
embedder_model_name="openai/text-embedding-ada-002"
```
### Premium Options
```python
# GPT-4o - Highest quality
model_name="openai/gpt-4o"
# text-embedding-3-large - Highest quality embeddings
embedder_model_name="openai/text-embedding-3-large"
```
Check [OpenRouter pricing](https://openrouter.ai/docs/pricing) for current rates.
## Model Selection Guide
### When to Use Each LLM Model
**GPT-4o** (`openai/gpt-4o`)
- Complex reasoning tasks
- Code generation and debugging
- Multi-step problem solving
- When accuracy is critical
- Best overall performance
**GPT-4o-mini** (`openai/gpt-4o-mini`)
- General purpose tasks
- High-volume applications
- Cost-sensitive use cases
- When 95% accuracy is sufficient
- Fast response times
**Claude** (`anthropic/claude-3.5-sonnet`)
- Long context tasks (200K tokens)
- Creative writing
- Detailed analysis
- When you need extended reasoning
- Complex document understanding
**Llama** (`meta-llama/llama-3.3-70b-instruct`)
- Open-source requirements
- Custom fine-tuning needs
- Privacy-sensitive applications
- Cost optimization
- Self-hosted deployments
### When to Use Each Embedding Model
**text-embedding-3-large** (`openai/text-embedding-3-large`)
- Default choice for most use cases
- High quality semantic search
- Best accuracy for retrieval
- Recommended for production
**text-embedding-3-small** (`openai/text-embedding-3-small`)
- Cost-sensitive applications
- Faster embedding generation
- Good quality for most tasks
- Large-scale deployments
**qwen3-embedding-8b** (`qwen/qwen3-embedding-8b`)
- Multilingual applications
- Higher dimensional space (4096)
- Better fine-grained distinctions
- When you need more embedding dimensions
## Best Practices
### 1. Start with Defaults
Begin with the default models (`gpt-4o` and `text-embedding-3-large`) for best balance of quality and performance.
### 2. Use Mini for Scale
Switch to `gpt-4o-mini` for high-volume operations where cost is a concern.
### 3. Match Embedder to Use Case
- Use `text-embedding-3-large` for quality-critical applications
- Use `text-embedding-3-small` for cost-sensitive deployments
- Use `qwen3-embedding-8b` for multilingual or high-dimensional needs
### 4. Set Max Tokens
Prevent runaway costs by setting appropriate `max_tokens` limits via environment variable:
```bash
export OPENROUTER_MAX_TOKENS="128000" # For long-context models
export OPENROUTER_MAX_TOKENS="8192" # For standard use cases
```
### 5. Monitor Costs
Regularly check the [OpenRouter dashboard](https://openrouter.ai/keys) to monitor usage and costs.
### 6. Use Analytics
Set `OPENROUTER_HTTP_REFERER` and `OPENROUTER_SITE_NAME` to track usage across different applications.
### 7. Clear Cache When Changing Models
If you switch embedder models, clear your vector database cache to avoid dimension mismatches.
### 8. Environment Variables for Production
Use environment variables for production deployments instead of hardcoding values:
```bash
# Production configuration
export OPENROUTER_API_KEY="sk-or-v1-..."
export OPENROUTER_MODEL="openai/gpt-4o-mini"
export OPENROUTER_EMBEDDER_MODEL="openai/text-embedding-3-large"
```
## Advanced Configuration
### Custom Dimensions for Unknown Models
If using an embedding model not in the known list, you can specify dimensions:
```bash
export OPENROUTER_EMBEDDER_MODEL="custom/embedding-model"
export OPENROUTER_EMBEDDER_DIMENSIONS="2048"
```
The service will also auto-detect dimensions from the first API response.
### Combining Multiple Configurations
All configuration is done via environment variables. Set multiple variables to configure different aspects:
```bash
# Set all configuration via environment variables
export OPENROUTER_MODEL="anthropic/claude-3.5-sonnet"
export OPENROUTER_MAX_TOKENS="200000"
export OPENROUTER_EMBEDDER_MODEL="openai/text-embedding-3-large"
```
## Additional Resources
- [OpenRouter Documentation](https://openrouter.ai/docs)
- [Available Models](https://openrouter.ai/models)
- [API Reference](https://openrouter.ai/docs/api-reference)
- [Pricing Information](https://openrouter.ai/docs/pricing)
- [Rate Limits](https://openrouter.ai/docs/api-reference/limits)
## Example: Complete Setup
Here's a complete example showing a production-ready setup:
```bash
# Set environment variables
export OPENROUTER_API_KEY="your-api-key-here"
export OPENROUTER_MODEL="openai/gpt-4o-mini"
export OPENROUTER_EMBEDDER_MODEL="openai/text-embedding-3-large"
export OPENROUTER_MAX_TOKENS="32768"
```
```python
import parlant.sdk as p
from parlant.sdk import NLPServices
@p.tool
async def get_weather(context: p.ToolContext, city: str) -> p.ToolResult:
# Your weather API logic here
return p.ToolResult(f"Sunny, 72°F in {city}")
async def main():
async with p.Server(nlp_service=NLPServices.openrouter) as server:
agent = await server.create_agent(
name="Weather Assistant",
description="Helps users check weather conditions."
)
await agent.create_guideline(
condition="User asks about weather",
action="Get weather information using the get_weather tool",
tools=[get_weather]
)
# 🎉 Ready at http://localhost:8800
if __name__ == "__main__":
import asyncio
asyncio.run(main())
```
This setup provides:
- ✅ Cost-effective LLM (`gpt-4o-mini`)
- ✅ High-quality embeddings (`text-embedding-3-large`)
- ✅ Reasonable token limit (32K)
- ✅ Tool integration
- ✅ Guideline-based behavior control
+84
View File
@@ -0,0 +1,84 @@
# Snowflake Cortex Adapter
Integrate [Snowflake Cortex REST APIs](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-rest-api) for **chat/structured generation** and **embeddings** with Snowflake-hosted LLMs. The adapter talks directly to:
- `POST /api/v2/cortex/inference:complete` for chat/JSON output
- `POST /api/v2/cortex/inference:embed` for embeddings
## Requirements
### Authentication
See [Snowflake REST API authentication](https://docs.snowflake.com/en/developer-guide/snowflake-rest-api/authentication). PAT is recommended.
### Environment Variables
See [Cortex models](https://docs.snowflake.com/en/user-guide/snowflake-cortex/llm) for available model names.
```bash
export SNOWFLAKE_CORTEX_BASE_URL="https://<account>.snowflakecomputing.com"
export SNOWFLAKE_AUTH_TOKEN="<jwt-or-pat>"
export SNOWFLAKE_CORTEX_CHAT_MODEL="mistral-large2"
export SNOWFLAKE_CORTEX_EMBED_MODEL="e5-base-v2"
# Optional:
export SNOWFLAKE_CORTEX_MAX_TOKENS="8192"
```
## Usage Example
```python
import parlant.sdk as p
from parlant.sdk import NLPServices
@p.tool
async def get_weather(context: p.ToolContext, city: str) -> p.ToolResult:
# Your weather API logic here
return p.ToolResult(f"Sunny, 72°F in {city}")
@p.tool
async def get_datetime(context: p.ToolContext) -> p.ToolResult:
from datetime import datetime
return p.ToolResult(datetime.now())
async def main():
async with p.Server(nlp_service=NLPServices.snowflake) as server:
agent = await server.create_agent(
name="WeatherBot",
description="Helpful weather assistant"
)
# Have the agent's context be updated on every response (though
# update interval is customizable) using a context variable.
await agent.create_variable(name="current-datetime", tool=get_datetime)
# Control and guide agent behavior with natural language
await agent.create_guideline(
condition="User asks about weather",
action="Get current weather and provide a friendly response with suggestions",
tools=[get_weather]
)
# Add other (reliably enforced) behavioral modeling elements
# ...
# 🎉 Test playground ready at http://localhost:8800
# Integrate the official React widget into your app,
# or follow the tutorial to build your own frontend!
if __name__ == "__main__":
import asyncio
asyncio.run(main())
```
## Configuration Reference
| Variable | Required | Description |
|----------------------------------|----------|-------------|
| `SNOWFLAKE_CORTEX_BASE_URL` | ✅ | Base account URL (e.g., `https://<account>.snowflakecomputing.com`). |
| `SNOWFLAKE_AUTH_TOKEN` | ✅ | OAuth / Keypair JWT / PAT used in the `Authorization: Bearer` header. |
| `SNOWFLAKE_CORTEX_CHAT_MODEL` | ✅ | Chat model name. |
| `SNOWFLAKE_CORTEX_EMBED_MODEL` | ✅ | Embedding model name. |
| `SNOWFLAKE_CORTEX_MAX_TOKENS` | ❌ | Local upper bound for generation; does not override provider limits. |
## Notes on Privacy & Data Residency
The adapter allows apps to call Cortex directly in your Snowflake account, reducing the need to move data outside Snowflake for LLM tasks. Review Snowflake's REST guidance for regional availability and account setup.
+452
View File
@@ -0,0 +1,452 @@
# Vertex AI Service Adapter Documentation
## Overview
The Vertex AI Service Adapter provides integration with Google Cloud's Vertex AI platform, supporting both Anthropic Claude models and Google Gemini models through their respective APIs. This adapter implements the Parlant NLP service interface for text generation, embeddings, and tokenization.
## Architecture
### Core Components
- **VertexAIService**: Main service class implementing the NLPService interface
- **VertexAIClaudeSchematicGenerator**: Generator for Claude models via Anthropic Vertex API
- **VertexAIGeminiSchematicGenerator**: Generator for Gemini models via Google Gen AI API
- **VertexAIEmbedder**: Text embedding service using Google's text-embedding-004 model
- **VertexAIEstimatingTokenizer**: Token counting for both Claude and Gemini models
## Configuration
### Environment Variables
```bash
# Required
VERTEX_AI_PROJECT_ID=your-gcp-project-id
VERTEX_AI_REGION=us-central1 # Put your region
VERTEX_AI_MODEL=claude-opus-4
```
### Authentication
The adapter uses Google Application Default Credentials (ADC):
```bash
# For local development
gcloud auth application-default login
# For production, use service account key or workload identity
```
## Supported Models
### Claude Models (via Anthropic Vertex API)
| Short Name | Full Model Name | Description |
|------------|-----------------|-------------|
| `claude-opus-4` | `claude-opus-4@20250514` | Most capable Claude model |
| `claude-sonnet-4` | `claude-sonnet-4@20250514` | Balanced performance and speed |
| `claude-sonnet-3.5` | `claude-3-5-sonnet-v2@20241022` | Previous generation Sonnet |
| `claude-haiku-3.5` | `claude-3-5-haiku@20241022` | Fastest Claude model |
### Gemini Models (via Google Gen AI API)
| Short Name | Full Model Name | Description |
|------------|-----------------|-------------|
| `gemini-2.5-flash` | `gemini-2.5-flash` | Latest fast Gemini model |
| `gemini-2.5-pro` | `gemini-2.5-pro` | Latest pro Gemini model |
| `gemini-2.0-flash` | `gemini-2.0-flash` | Previous generation flash |
| `gemini-1.5-flash` | `gemini-1.5-flash` | 1M token context |
| `gemini-1.5-pro` | `gemini-1.5-pro` | 2M token context |
## Usage
### Basic Setup
```python
import parlant.sdk import p
from parlant.sdk import NLPServices
async with p.Server(nlp_service=NLPServices.vertex) as server:
agent = await server.create_agent(
name="Healthcare Agent",
description="Is empathetic and calming to the patient.",
)
```
### Direct Service Usage
```python
from parlant.adapters.nlp.vertex_service import VertexAIService
from parlant.core.loggers import Logger
# Initialize service
logger = Logger()
service = VertexAIService(logger=logger)
# Get schematic generator
generator = await service.get_schematic_generator(YourSchemaClass)
# Generate content
result = await generator.generate(
prompt="Your prompt here",
hints={"temperature": 0.7, "max_tokens": 1000}
)
```
## API Reference
### VertexAIService
Main service class implementing the NLPService interface.
#### Constructor
```python
def __init__(self, logger: Logger) -> None
```
Initializes the service with environment variables:
- Reads `VERTEX_AI_PROJECT_ID`, `VERTEX_AI_REGION`, `VERTEX_AI_MODEL`
- Validates Application Default Credentials
- Sets up logging
#### Methods
##### get_schematic_generator
```python
async def get_schematic_generator(self, t: type[T]) -> SchematicGenerator[T]
```
Returns appropriate generator based on configured model:
- Claude models → VertexAIClaudeSchematicGenerator
- Gemini models → VertexAIGeminiSchematicGenerator
- Includes fallback logic for Claude Opus 4
##### get_embedder
```python
async def get_embedder(self) -> Embedder
```
Returns VertexTextEmbedding004 embedder instance.
##### get_moderation_service
```python
async def get_moderation_service(self) -> ModerationService
```
Returns NoModeration service (moderation not yet implemented).
### VertexAIClaudeSchematicGenerator
Schematic generator for Claude models via Anthropic Vertex API.
#### Supported Hints
- `temperature`: Controls randomness (0.0-1.0)
- `max_tokens`: Maximum output tokens
- `top_p`: Nucleus sampling parameter
- `top_k`: Top-k sampling parameter
#### Properties
- `id`: Returns `vertex-ai/{model_name}`
- `tokenizer`: Returns VertexAIEstimatingTokenizer instance
- `max_tokens`: Returns 200,000 (Claude context limit)
#### Methods
##### generate
```python
async def generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]
```
Generates structured content using Claude models with:
- JSON schema validation
- Retry policies for rate limits and errors
- Usage tracking
### VertexAIGeminiSchematicGenerator
Schematic generator for Gemini models via Google Gen AI API.
#### Supported Hints
- `temperature`: Controls randomness (0.0-1.0)
- `thinking_config`: Configuration for reasoning models
#### Properties
- `id`: Returns `vertex-ai/{model_name}`
- `tokenizer`: Returns VertexAIEstimatingTokenizer instance
- `max_tokens`: Returns 1M (Flash) or 2M (Pro) tokens
#### Methods
##### generate
```python
async def generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]
```
Generates structured content using Gemini models with:
- Native JSON schema support
- JSON parsing and validation
- Usage metadata tracking
### VertexAIEmbedder
Text embedding service using Google's text-embedding-004 model.
#### Properties
- `id`: Returns `vertex-ai/text-embedding-004`
- `dimensions`: Returns 768 (embedding dimensions)
- `max_tokens`: Returns 8,192 (input token limit)
#### Supported Hints
- `title`: Document title for better embeddings
- `task_type`: Embedding task type (default: "RETRIEVAL_DOCUMENT")
#### Methods
##### embed
```python
async def embed(
self,
texts: list[str],
hints: Mapping[str, Any] = {},
) -> EmbeddingResult
```
Generates embeddings for input texts with batch processing support.
### VertexAIEstimatingTokenizer
Token counting service supporting both Claude and Gemini models.
#### Methods
##### estimate_token_count
```python
async def estimate_token_count(self, prompt: str) -> int
```
Estimates token count using:
- tiktoken for Claude models
- Google Gen AI API for Gemini models
## Error Handling
### Authentication Errors
```python
class VertexAIAuthError(Exception):
"""Raised when there are authentication issues with Vertex AI."""
```
Common causes and solutions:
- Missing ADC: Run `gcloud auth application-default login`
- Insufficient permissions: Ensure "Vertex AI User" role
- Model not enabled: Check Vertex AI Model Garden
### Rate Limiting
The adapter implements comprehensive retry policies:
#### Claude Models
- Retries: APIConnectionError, APITimeoutError, RateLimitError, APIResponseValidationError
- Max attempts: 3 with exponential backoff (1s, 2s, 4s)
- Server errors: 2 attempts with longer delays (1s, 5s)
#### Gemini Models
- Retries: NotFound, TooManyRequests, ResourceExhausted
- Max attempts: 3 with exponential backoff (1s, 2s, 4s)
- Server errors: 2 attempts with longer delays (1s, 5s)
### Error Messages
The adapter provides detailed error messages for common issues:
#### Rate Limit Exceeded
```
Vertex AI rate limit exceeded. Possible reasons:
1. Your GCP project may have insufficient quota.
2. The model may not be enabled in Vertex AI Model Garden.
3. You might have exceeded the requests-per-minute limit.
Recommended actions:
- Check your Vertex AI quotas in the GCP Console.
- Ensure the model is enabled in Vertex AI Model Garden.
- Review IAM permissions for the service account.
- Visit: https://console.cloud.google.com/vertex-ai/model-garden
```
#### Permission Denied
```
Permission denied accessing Vertex AI. Ensure:
1. ADC is properly configured (run 'gcloud auth application-default login')
2. The service account has 'Vertex AI User' role
3. The {model_name} model is enabled in Vertex AI Model Garden
```
## Performance Considerations
### Token Limits
| Model Type | Context Limit | Recommended Usage |
|------------|---------------|-------------------|
| Claude Models | 200K tokens | Long documents, complex reasoning |
| Gemini Flash | 1M tokens | Large context processing |
| Gemini Pro | 2M tokens | Maximum context requirements |
## Best Practices
### Model Selection
1. **Claude Sonnet 3.5**: Best balance of performance and cost
2. **Claude Opus 4**: Maximum capability with fallback
3. **Gemini 2.5 Flash**: Fast processing with large context
4. **Gemini 2.5 Pro**: Complex reasoning tasks
### Configuration
```python
export VERTEX_AI_PROJECT_ID=your-project-id
export VERTEX_AI_REGION=us-central1
export VERTEX_AI_MODEL=claude-sonnet-3.5
```
### Error Handling
```python
from parlant.adapters.nlp.vertex_service import VertexAIAuthError
try:
service = VertexAIService(logger=logger)
generator = await service.get_schematic_generator(MySchema)
result = await generator.generate(prompt)
except VertexAIAuthError as e:
logger.error(f"Authentication failed: {e}")
# Handle auth setup
except Exception as e:
logger.error(f"Generation failed: {e}")
# Handle other errors
```
## Troubleshooting
### Common Issues
1. **Authentication Failures**
- Verify ADC setup: `gcloud auth application-default print-access-token`
- Check project permissions in GCP Console
- Ensure service account has required roles
2. **Model Access Denied**
- Enable models in Vertex AI Model Garden
- Check regional availability
- Verify billing account is active
3. **Rate Limiting**
- Monitor quota usage in GCP Console
- Implement application-level rate limiting
- Consider upgrading service tier
### Debugging
Check usage from the playground UI by inspecting on the generated message
## Migration Guide
### From Other Adapters
When migrating from other NLP adapters:
1. **Update Environment Variables**
```bash
# Remove old variables
unset OPENAI_API_KEY ANTHROPIC_API_KEY
# Set Vertex AI variables
export VERTEX_AI_PROJECT_ID=your-project-id
export VERTEX_AI_REGION=us-central1
export VERTEX_AI_MODEL=claude-opus-4
```
2. **Model Name Mapping**
- `gpt-4` → `claude-opus-4`
- `gpt-3.5-turbo` → `gemini-2.5-flash`
- `claude-3-sonnet` → `claude-opus-4`
## Contributing
### Adding New Models
1. **Determine Provider**: Check if model uses Anthropic or Google API
2. **Create Model Class**: Inherit from appropriate base generator
3. **Update Service**: Add model mapping in VertexAIService
4. **Add Tests**: Include integration tests for new model
5. **Update Documentation**: Add model to supported models table
### Code Style
- Follow existing patterns for error handling
- Include comprehensive logging
- Add type hints for all methods
- Document public APIs with docstrings
- Use retry policies for external API calls
## Prerequisites and Installation
### Installation
To use the Vertex AI Service Adapter with Parlant, you need to install the appropriate optional dependencies:
```bash
pip install "parlant[vertex]"
```
This installation includes support for both Claude and Gemini models through the Vertex AI platform.
### Important Model Deprecation Notice
⚠️ **Claude 3.5 Sonnet Models Deprecation**: Claude Sonnet 3.5 models (claude-3-5-sonnet-20240620 and claude-3-5-sonnet-20241022) will be retired on October 22, 2025. We recommend migrating to Claude Sonnet 4 (claude-sonnet-4-20250514) for improved performance and capabilities.
## Authentication Setup
Before using the adapter, ensure you have proper authentication configured:
```bash
# For local development
gcloud auth application-default login
# Verify authentication
gcloud auth application-default print-access-token
```
## Required Permissions
Ensure your service account or user has the following IAM roles:
- `Vertex AI User` - for accessing Vertex AI services
- `AI Platform User` - for model access (legacy role, may be needed for some models)
## License
Licensed under the Apache License, Version 2.0. See the source file header for full license text.
## Maintainer
Agam Dubey - hello.world.agam@gmail.com
+154
View File
@@ -0,0 +1,154 @@
# Snowflake Persistence Adapter
The Snowflake document adapter lets Parlant persist the longlived parts of a
deployment—sessions, customers, and context variables—inside your Snowflake
account. That means you can run the server (for example inside Snowpark
Container Services), stop it, and later resume the exact same conversation
state.
This page walks through the required environment variables and shows how to
wire the stores into Snowflake when booting Parlant via the SDK.
## Requirements
1. Install the optional dependency (or otherwise provide
`snowflake-connector-python`):
```bash
pip install "parlant[snowflake]"
```
2. Set the credentials that `SnowflakeDocumentDatabase` consumes:
| Variable | Required | Description |
|-----------------------------|:--------:|-------------------------------------------------------------------------------------|
| `SNOWFLAKE_ACCOUNT` | ✅ | Account locator (e.g. `abc-xy123`). |
| `SNOWFLAKE_USER` | ✅ | Username that Parlant should authenticate as. |
| `SNOWFLAKE_PASSWORD` | ✅* | Password for password-based auth. Skip when using OAuth (see `SNOWFLAKE_TOKEN`). |
| `SNOWFLAKE_TOKEN` | ✅* | OAuth access token. When set, the adapter automatically switches to OAuth. |
| `SNOWFLAKE_WAREHOUSE` | ✅ | Warehouse to execute queries against. |
| `SNOWFLAKE_DATABASE` | ✅ | Database that will host the Parlant tables. |
| `SNOWFLAKE_SCHEMA` | ✅ | Schema inside the database. |
| `SNOWFLAKE_ROLE` | | Optional role override. |
> ✅* Provide **either** `SNOWFLAKE_PASSWORD` **or** `SNOWFLAKE_TOKEN`.
## SDK / Module Setup
Parlants SDK exposes a `configure_container` hook that lets you replace the
default persistence layer. The pattern below shows how to register
Snowflake-backed implementations of the three configurable stores:
- `SessionStore` → `SessionDocumentStore`
- `CustomerStore` → `CustomerDocumentStore`
- `ContextVariableStore` → `ContextVariableDocumentStore`
Each store receives its own table prefix (`PARLANT_SESSIONS_`,
`PARLANT_CUSTOMERS_`, `PARLANT_CONTEXT_VARIABLES_`) so their metadata never
collides. We also rebind `EventEmitterFactory`, so system events get written into
the same store.
```python
from contextlib import AsyncExitStack
import parlant.sdk as p
from parlant.adapters.db.snowflake_db import SnowflakeDocumentDatabase
from parlant.core.emission.event_publisher import EventPublisherFactory
EXIT_STACK = AsyncExitStack()
async def _make_session_store(container: p.Container) -> p.SessionStore:
database = await EXIT_STACK.enter_async_context(
SnowflakeDocumentDatabase(
logger=container[p.Logger],
table_prefix="PARLANT_SESSIONS_",
)
)
store = p.SessionDocumentStore(database=database, allow_migration=True)
return await EXIT_STACK.enter_async_context(store)
async def _make_customer_store(container: p.Container) -> p.CustomerStore:
database = await EXIT_STACK.enter_async_context(
SnowflakeDocumentDatabase(
logger=container[p.Logger],
table_prefix="PARLANT_CUSTOMERS_",
)
)
store = p.CustomerDocumentStore(
id_generator=container[p.IdGenerator],
database=database,
allow_migration=True,
)
return await EXIT_STACK.enter_async_context(store)
async def _make_variable_store(container: p.Container) -> p.ContextVariableStore:
database = await EXIT_STACK.enter_async_context(
SnowflakeDocumentDatabase(
logger=container[p.Logger],
table_prefix="PARLANT_CONTEXT_VARIABLES_",
)
)
store = p.ContextVariableDocumentStore(
id_generator=container[p.IdGenerator],
database=database,
allow_migration=True,
)
return await EXIT_STACK.enter_async_context(store)
async def configure_container(container: p.Container) -> p.Container:
container = container.clone()
session_store = await _make_session_store(container)
container[p.SessionDocumentStore] = session_store
container[p.SessionStore] = session_store
customer_store = await _make_customer_store(container)
container[p.CustomerDocumentStore] = customer_store
container[p.CustomerStore] = customer_store
variable_store = await _make_variable_store(container)
container[p.ContextVariableDocumentStore] = variable_store
container[p.ContextVariableStore] = variable_store
container[p.EventEmitterFactory] = EventPublisherFactory(
container[p.AgentStore],
session_store,
)
return container
async def shutdown_snowflake() -> None:
await EXIT_STACK.aclose()
```
### Using the SDK
```python
async def main() -> None:
try:
async with p.Server(
nlp_service=p.NLPServices.snowflake,
configure_container=configure_container,
) as server:
...
finally:
await shutdown_snowflake()
```
## What Gets Persisted?
Once the Snowflake stores are registered, Snowflake becomes the source of truth for:
- Sessions + events + inspections
- Customers + their tag associations
- Context variables + their values
Other stores (agents, guidelines, journeys, etc.) continue to use their default
backends. If you define them in code at startup, they will automatically be
recreated each time the server runs. For dynamic authoring flows you can follow
the same module approach to route additional stores into Snowflake.
+204
View File
@@ -0,0 +1,204 @@
# Qdrant Vector Database
The Qdrant adapter provides persistent vector storage for Parlant using Qdrant's vector database. This replaces the default in-memory storage with production-ready persistence.
For general Parlant usage, see the [official documentation](https://www.parlant.io/docs/).
## Prerequisites
1. **Install Qdrant adapter**: `pip install parlant[qdrant]`
2. **Choose storage**: Local file system or Qdrant Cloud
## Quick Start
### Setup (Manual)
```python
import parlant.sdk as p
from pathlib import Path
from contextlib import AsyncExitStack
from parlant.adapters.vector_db.qdrant import QdrantDatabase
from parlant.core.nlp.embedding import EmbedderFactory, EmbeddingCache, Embedder
from parlant.core.loggers import Logger
from parlant.core.nlp.service import NLPService
from parlant.core.glossary import GlossaryVectorStore, GlossaryStore
from parlant.core.canned_responses import CannedResponseVectorStore, CannedResponseStore
from parlant.core.capabilities import CapabilityVectorStore, CapabilityStore
from parlant.core.journeys import JourneyVectorStore, JourneyStore
from parlant.adapters.db.transient import TransientDocumentDatabase
async def configure_container(container: p.Container) -> p.Container:
embedder_factory = EmbedderFactory(container)
async def get_embedder_type() -> type[Embedder]:
return type(await container[NLPService].get_embedder())
exit_stack = AsyncExitStack()
qdrant_db = await exit_stack.enter_async_context(
QdrantDatabase(
logger=container[Logger],
path=Path("./qdrant_data"),
embedder_factory=EmbedderFactory(container),
embedding_cache_provider=lambda: container[EmbeddingCache],
)
)
# For Qdrant Cloud, replace the above with:
# qdrant_db = await exit_stack.enter_async_context(
# QdrantDatabase(
# logger=container[Logger],
# url="https://your-cluster-id.us-east4-0.gcp.cloud.qdrant.io",
# api_key="your-api-key-here",
# embedder_factory=EmbedderFactory(container),
# embedding_cache_provider=lambda: container[EmbeddingCache],
# )
# )
# Configure stores using vector database
container[GlossaryStore] = await exit_stack.enter_async_context(
GlossaryVectorStore(
id_generator=container[p.IdGenerator],
vector_db=qdrant_db,
document_db=TransientDocumentDatabase(),
embedder_factory=embedder_factory,
embedder_type_provider=get_embedder_type,
) # type: ignore
)
container[CannedResponseStore] = await exit_stack.enter_async_context(
CannedResponseVectorStore(
id_generator=container[p.IdGenerator],
vector_db=qdrant_db,
document_db=TransientDocumentDatabase(),
embedder_factory=embedder_factory,
embedder_type_provider=get_embedder_type,
) # type: ignore
)
container[CapabilityStore] = await exit_stack.enter_async_context(
CapabilityVectorStore(
id_generator=container[p.IdGenerator],
vector_db=qdrant_db,
document_db=TransientDocumentDatabase(),
embedder_factory=embedder_factory,
embedder_type_provider=get_embedder_type,
) # type: ignore
)
container[JourneyStore] = await exit_stack.enter_async_context(
JourneyVectorStore(
id_generator=container[p.IdGenerator],
vector_db=qdrant_db,
document_db=TransientDocumentDatabase(),
embedder_factory=embedder_factory,
embedder_type_provider=get_embedder_type,
) # type: ignore
)
return container
async def main():
async with p.Server(configure_container=configure_container) as server:
agent = await server.create_agent(
name="My Agent",
description="Agent using Qdrant for persistent storage",
)
# Test: Create a term to verify Qdrant is working
term = await agent.create_term(
name="Example Term",
description="This is stored in Qdrant",
)
print(f"Created term: {term.name}")
# All vector operations now use Qdrant
```
## Verification
To verify Qdrant integration is working correctly:
### Check Collections
**Qdrant Cloud:** Collections appear in your Qdrant dashboard with names like:
- `glossary_OpenAITextEmbedding3Large`
- `glossary_unembedded`
- `capabilities_OpenAITextEmbedding3Large`
- `canned_responses_OpenAITextEmbedding3Large`
**Local Qdrant:** A folder is created at your specified path containing Qdrant database files.
### Confirm No Transient Storage
When Qdrant is properly configured:
- **No vector files** are created in the `parlant-data` folder
- Vector data is stored only in Qdrant (cloud or local)
- Data persists across server restarts
### Test Vector Search
Create terms and test persistence:
```python
term = await agent.create_term(
name="Test Term",
description="This should be stored in Qdrant",
)
# Then chat with agent about "test term" - it should understand via vector search
# Test persistence: close the server and run again
# The term should still be available after restart
```
---
## Common Issues
### Integration Not Working (Still Using Transient Storage)
**Symptoms:**
- No collections appear in Qdrant dashboard
- Vector data appears in `parlant-data` folder
- Data lost on server restart
**Solution:** Ensure all vector stores are properly configured with Qdrant in your `configure_container` function. Make sure you're using `AsyncExitStack` to properly manage the Qdrant database and vector stores lifecycle.
### Windows File Locks
On Windows, use `async with` context manager. The adapter automatically handles file lock retries.
### Collection Sync
Collections auto-sync when embedders or schemas change. Large collections may take time on first access.
### Embedder Changes
When changing embedder types, old embedded collections persist until manually deleted.
### Performance
Use Qdrant Cloud or server for production - local mode doesn't support payload indexes. You'll see a warning about this when using local Qdrant, which is expected and can be ignored.
---
## Troubleshooting
### Connection Issues
- **Local**: Check path exists and is writable
- **Remote**: Verify URL and API key
### Slow Performance
- Use embedding cache
- Use Qdrant Cloud/server for payload indexes
- Consider splitting large collections
### Data Not Persisting
- Check file path is correct and writable
- Verify connection settings for remote servers
- Test by closing the server and restarting—data should persist
---
## Requirements
- Python 3.8+
- `pip install parlant[qdrant]`
- Writable directory (for local storage) or Qdrant Cloud account
## Key Features
- **Persistent storage**: Replaces in-memory storage with production-ready persistence
- **Auto-sync**: Collections automatically sync when embedders or schemas change
- **Windows support**: Automatic file lock handling
+9
View File
@@ -0,0 +1,9 @@
# Contributing to Parlant
We use the Linux-standard Developer Certificate of Origin ([DCO.md](https://github.com/emcie-co/parlant/blob/develop/DCO.md)), so that, by contributing, you confirm that you have the rights to submit your contribution under the Apache 2.0 license (i.e., the code you're contributing is truly yours to share with the project).
Please consult [CONTRIBUTING.md](https://github.com/emcie-co/parlant/blob/develop/CONTRIBUTING.md) for more details.
Want to start getting involved right now? Join us on [Discord](https://discord.gg/duxWqxKk6J) and let's discuss how you can help shape Parlant. We're excited to work with contributors directly while we set up our formal processes.
Otherwise, feel free to start a discussion or open an issue on [GitHub](https://github.com/emcie-co/parlant).
+381
View File
@@ -0,0 +1,381 @@
# Custom NLP Models
Once you've understood the basic of setting up [engine extensions](https://parlant.io/docs/advanced/engine-extensions), you can integrate other NLP models into Parlant.
> **A Note on Custom Models**
>
> Parlant was optimized to work with the built-in LLMs, so using other models may require additional configuration and testing.
>
> In particular, please note that Parlant uses some complex output JSON schemas in its operation. This means that you either need a powerful model that can handle complex outputs, or, alternatively, that you use a smaller model (SLM) that has been fine-tuned on Parlant data specifically, using a larger model as a teacher.
>
> Using smaller models is actually a great way to reduce costs, latency—and sometimes even accuracy—in production environments.
## Understanding `NLPService`
Whether you want to use a different model from a supported built-in provider, or an entirely different provider, you can do so by creating a custom `NLPService` implementation.
An `NLPService` has 3 key components:
1. **Schematic Generators**: These are used to generate structured content based on prompts.
2. **Embedders**: These are used to create vector representations of text for semantic retrieval.
3. **Moderation Service**: This is used to filter out harmful or inappropriate user input in conversations.
> **Reference Example**
>
> You can take a look at the official [`OpenAIService`](https://github.com/emcie-co/parlant/blob/main/src/parlant/adapters/nlp/openai_service.py) for a production-ready reference implementation of an `NLPService`.
### Schematic Generation
Throughout the Parlant engine, you'll find references to `SchematicGenerator[T]` objects. These are objects that generate [Pydantic](https://docs.pydantic.dev/latest/) models using instructions in a provided prompt. Behind the scenes, they always use LLMs to generate JSON schemas that in turn are converted to Pydantic models.
All LLM requests in Parlant are actually made using these schematic generators, which means that, whatever model you use, it must be able to generate valid JSON schemas consistently. This is the only requirement for a model in Parlant.
Let's now look at a few important interfaces that you need to implement in your custom NLP service.
#### Estimating Tokenizers
The `EstimatingTokenizer` interface is used to estimate the number of tokens in a prompt. This is important for managing costs and rate limits when using LLM APIs. It's also used in embedding models, where Parlant needs to chunk the input text into smaller parts to fit within the model's context window.
The reason it's called "estimating" is because not all model APIs provide exact token counts.
```python
class EstimatingTokenizer(ABC):
"""An interface for estimating the token count of a prompt."""
@abstractmethod
async def estimate_token_count(self, prompt: str) -> int:
"""Estimate the number of tokens in the given prompt."""
...
```
For example, with `OpenAI`, you can implement this to use the `tiktoken` library to get accurate token counts for GPT models, or estimated token counts for other popular models.
#### Schematic Generators
Now let's look at the `SchematicGenerator[T]` interface itself, which is used to generate structured content based on a prompt.
Each generation result from a `SchematicGenerator[T]` contains not just the generated object, but also additional metadata about the generation process. Here's what it looks like:
```python
@dataclass(frozen=True)
class SchematicGenerationResult(Generic[T]):
content: T # The generated schematic content (a Pydantic model instance)
info: GenerationInfo # Metadata about the generation process
@dataclass(frozen=True)
class GenerationInfo:
schema_name: str # The name of the Pydantic schema used for the generated content
model: str # The name of the model used for generation
duration: float # Time taken for the generation in seconds
usage: UsageInfo # Token usage information
@dataclass(frozen=True)
class UsageInfo:
input_tokens: int
output_tokens: int
extra: Optional[Mapping[str, int]] = None # May contain metrics like cached input tokens
```
Now let's look at the `SchematicGenerator[T]` interface itself, which you'd need to implement for your custom model:
```python
class SchematicGenerator(ABC, Generic[T]):
"""An interface for generating structured content based on a prompt."""
@abstractmethod
async def generate(
self,
# The prompt (or PromptBuilder) containing instructions for the generation.
prompt: str | PromptBuilder,
# Hints are a good way to provide additional context or parameters for the generation,
# such as temperature, top P, logit bias, and things of that nature.
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
"""Generate content based on the provided prompt and hints."""
# Implement this method to generate content using your own model.
...
@property
@abstractmethod
def id(self) -> str:
"""Return a unique identifier for the generator."""
# Normally, this would be the model name or ID used in the LLM API.
...
@property
@abstractmethod
def max_tokens(self) -> int:
"""Return the maximum number of tokens in the underlying model's context window."""
# Return the maximum number of tokens that can be processed by your model.
...
@property
@abstractmethod
def tokenizer(self) -> EstimatingTokenizer:
"""Return a tokenizer that approximates that of the underlying model."""
# This tokenizer should be able to estimate token counts for prompts for this model.
...
@cached_property
def schema(self) -> type[T]:
"""Return the schema type for the generated content.
This is useful for derived classes, allowing them to access the concrete
schema type for the current instance without needing to know the type parameter.
"""
# You don't need to implement this method - it's an inherited convenience method.
orig_class = getattr(self, "__orig_class__")
generic_args = get_args(orig_class)
return cast(type[T], generic_args[0])
```
> **Reference Example**
>
> You can take a look at the official [`OpenAIService`](https://github.com/emcie-co/parlant/blob/main/src/parlant/adapters/nlp/openai_service.py) for a production-ready reference implementation of an `SchematicGenerator[T]`.
### Embedding
In addition to generating structured content, Parlant also uses embedders to create vector representations of text. These are used for semantic retrieval where applicable throughout the response lifecycle.
#### Embedding Results
Every embedding operation returns an `EmbeddingResult`, which contains the vectors generated by the embedder:
```python
@dataclass(frozen=True)
class EmbeddingResult:
vectors: Sequence[Sequence[float]]
```
#### Embedders
Now let's look at the `Embedder` interface and how to implement it:
```python
class Embedder(ABC):
@abstractmethod
async def embed(
self,
texts: list[str],
hints: Mapping[str, Any] = {},
) -> EmbeddingResult:
# Generate embeddings for the given texts.
...
@property
@abstractmethod
def id(self) -> str:
# Return a unique identifier for the embedder - usually the model name or ID.
...
@property
@abstractmethod
def max_tokens(self) -> int:
# Return the maximum number of tokens in the model's context window.
...
@property
@abstractmethod
def tokenizer(self) -> EstimatingTokenizer:
# Return a tokenizer that approximates the model's token count for prompts.
...
@property
@abstractmethod
def dimensions(self) -> int:
# Return the dimensionality of the embedding space.
...
```
> **Reference Example**
>
> You can take a look at the official [`OpenAIService`](https://github.com/emcie-co/parlant/blob/main/src/parlant/adapters/nlp/openai_service.py) for a production-ready reference implementation of an `Embedder`.
### Moderation Services
Parlant includes a comprehensive content moderation system to filter harmful or inappropriate user input. The moderation service is the third key component of an `NLPService`, alongside schematic generators and embedders.
#### Understanding Moderation in Parlant
Parlant's moderation system provides content filtering capabilities that can detect and flag various types of harmful content before it reaches your AI agents. The engine can integrate with all stsandard moderation providers and can be configured with different levels of strictness.
#### Moderation Interface
All moderation services implement the `ModerationService` abstract base class:
```python
@dataclass(frozen=True)
class CustomerModerationContext:
"""Context for moderation check"""
session: Session # Session context for the message being checked
message: str # The content of the message to check
@dataclass(frozen=True)
class ModerationCheck:
"""Result of a moderation check."""
flagged: bool # Whether the content was flagged as inappropriate
tags: list[str] # Specific categories that were flagged
class ModerationService(ABC):
"""Abstract base class for content moderation services."""
@abstractmethod
async def moderate_customer(self, context: CustomerModerationContext) -> ModerationCheck:
"""Check content for policy violations and return moderation result."""
...
```
#### Moderation Tags
Parlant uses standardized moderation tags that map to common content policy categories:
```python
ModerationTag: TypeAlias = Literal[
"jailbreak", # Prompt injection attempts
"harassment", # Harassment or bullying content
"hate", # Hate speech or discrimination
"illicit", # Illegal activities or substances
"self-harm", # Self-harm or suicide content
"sexual", # Sexual or adult content
"violence", # Violence or graphic content
]
```
#### Implementing Custom Moderation Services
Here's how to create your own moderation service:
```python
import httpx
import parlant.sdk as p
class MyModerationService(p.ModerationService):
def __init__(self, api_key: str, logger: p.Logger):
self._api_key = api_key
self._logger = logger
self._client = httpx.AsyncClient()
async def moderate_customer(self, context: p.CustomerModerationContext) -> p.ModerationCheck:
"""Implement your moderation logic here."""
try:
# Example: Call your moderation API
response = await self._client.post(
"https://api.your-moderation-service.com/moderate",
json={"text": context.message},
headers={"Authorization": f"Bearer {self._api_key}"}
)
response.raise_for_status()
result = response.json()
# Map your service's response to Parlant's format
flagged = result.get("flagged", False)
categories = result.get("categories", [])
# Convert your categories to Parlant's standardized tags
tags = []
category_mapping = {
"toxic": "harassment",
"hate_speech": "hate",
"violence": "violence",
"sexual_content": "sexual",
"self_harm": "self-harm",
"illegal": "illicit",
"prompt_injection": "jailbreak",
}
for category in categories:
if category in category_mapping:
tags.append(category_mapping[category])
return p.ModerationCheck(
flagged=flagged,
tags=tags,
)
except Exception as e:
self._logger.error(f"Moderation check failed: {e}")
# Fail closed: return unflagged to allow content through
# Or fail open: return flagged to block content
return p.ModerationCheck(flagged=False, tags=[])
```
## Customizing Prompts
When you implement your own `SchematicGenerator[T]`, you can also customize the prompts it actually uses.
This is achieved via the `PromptBuilder` class. It's the same class used throughout the Parlant engine to build prompts for LLMs using consistent rules and formats, and it allows you to access and modify prompt templates.
One of the cool things you can do with it is to edit specific prompt sections right before you build the final prompt.
Let's look at an example of how we'd override the draft creation prompt of the `CannedResponseGenerator`.
```python
class MySchematicGenerator(p.SchematicGenerator[p.T]):
async def generate(
self,
prompt: str | p.PromptBuilder,
hints: Mapping[str, Any] = {},
) -> p.SchematicGenerationResult[T]:
def edit_draft_instructions(section: p.PromptSection) -> p.PromptSection:
# You can inspect the section's dynamically-passed properties
# to see what you can make use of in your modified template.
section.props
section.template = f"""
Write your custom instructions here ...
Pass in dynamic props where needed: {section.props}
"""
return section
prompt.edit_section(
name="canned-response-generator-draft-general-instructions",
editor_func=edit_draft_instructions,
)
# Call the parent class's generate method with the modified prompt
return await super().generate(prompt, hints)
```
You can modify any section used anywhere within Parlant. You can find these sections by looking at references to `PromptBuilder.add_section()` in the Parlant codebase.
## Implementing an `NLPService`
Now that you understand the key interfaces, you can implement your own `NLPService`. This is the easy part.
Here's what that would look like:
```python
class MyNLPService(p.NLPService):
def __init__(self, logger: p.Logger):
self.logger = logger
async def get_schematic_generator(self, t: type[p.T]) -> p.SchematicGenerator[p.T]:
# Return your custom schematic generator for the given type.
return MySchematicGenerator[p.T](
logger=self.logger, # Assuming you use a logger
)
async def get_embedder(self) -> p.Embedder:
return MyEmbedder(
logger=self.logger, # Assuming you use a logger
)
async def get_moderation_service(self) -> p.ModerationService:
# Return your custom moderation service implementation.
# If you don't need moderation, return NoModeration().
return MyModerationService(logger=self.logger)
```
## Injecting a Custom `NLPService`
Once you've implemented your custom `NLPService`, you can easily register it with your Parlant server.
You also get a reference to the dependency-injection container, from which you can access the system's logger and other services, as needed.
```python
def load_custom_nlp_service(container: p.Container) -> p.NLPService:
return MyNLPService(
logger=container[p.Logger]
)
```
Then, when you start your Parlant server, pass your loader function to the `nlp_service` parameter:
```python
async with p.Server(
nlp_service=load_custom_nlp_service,
) as server:
# Your code here
```
+132
View File
@@ -0,0 +1,132 @@
# Engine Extensions
Working with an external framework and adapting it to your needs isnt always simple, especially when you need it to behave in ways its original design didnt anticipate.
Modifying the frameworks source code is a treacherous path—not just because it requires deeper expertise, but also because it leads to divergences between your locally-modified version and upstream updates.
So how do you get a pre-built framework to work differently? The idea is to be able to run a system or software that includes your code customizations without breaking its fundamental assumptions.
The [Open/Closed Principle](https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle) states that software should be open for extension, but closed for modification, such that it can allow its behavior to be extended without modifying its source code. Parlant is carefully designed to abide by this principle, allowing you to achieve extreme extensibility by hooking into its structure.
With extensions, you are free to build exactly what you need without waiting for updates or modifying core engine components. This is a good time to remind you that you can join our [Discord](https://discord.gg/duxWqxKk6J) community to ask questions.
## Engine Hooks
Every time an agent needs to respond to a customer, the engine goes through a series of steps to generate the response. You can hook into these steps to modify the behavior of the engine. This is easily done by registering hook functions.
While there are many hooks you can utilize, here's a simple example that:
1. Overrides the entire engine's response generation process if we detect that the customer only greeted the agent.
1. Inspects the agent's message for compliance breaches (using a custom checker) before sending it to the customer.
```python
import asyncio
from typing import Any
import parlant.sdk as p
async def intercept_message_generation_with_greeting(
ctx: p.LoadedContext, payload: Any, exc: Exception | None
) -> p.EngineHookResult:
if await is_only_greeting(ctx.interaction.last_customer_message):
await ctx.session_event_emitter.emit_message_event(
trace_id=ctx.tracer.trace_id,
data="Hello! How can I help you today?",
)
return p.EngineHookResult.BAIL # Intercept the rest of the process
else:
return p.EngineHookResult.CALL_NEXT # Continue with the normal process
async def check_message_compliance(
ctx: p.LoadedContext, payload: Any, exc: Exception | None
) -> p.EngineHookResult:
generated_message = payload
if not await is_compliant(generated_message):
ctx.logger.warning(f"Prevented sending a non-compliant message: '{generated_message}'.")
return p.EngineHookResult.BAIL # Do not send this message
return p.EngineHookResult.CALL_NEXT # Continue with the normal process
async def configure_hooks(hooks: p.EngineHooks) -> p.EngineHooks:
hooks.on_acknowledged.append(intercept_message_generation_with_greeting)
hooks.on_message_generated.append(check_message_compliance)
return hooks
async def main():
async with p.Server(
configure_hooks=configure_hooks,
) as server:
# Your logic here
...
```
## Dependency Injection
In order to extend the engine without modifying its source code, Parlant uses a [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) system. This allows you to inject your own implementations of various components or even the processing engine itself (say, if you wanted to optimize the entire pipeline for particular use cases).
For simplicity, we'll take a look at some basic extension mechanics, as well as common use cases for extension.
However, if you need help with something that isn't covered here, please reach out to us on [Discord](https://discord.gg/duxWqxKk6J), [GitHub Discussions](https://github.com/emcie-co/parlant/discussions), or simply using the [Contact Page](https://parlant.io/contact) and we'll get back to you.
### Working with the Container
Let's see how to work with Parlant's dependency injection container. The container is a central place where all components are registered, and you can use it to retrieve or register your own components.
There are two things you might want to do with respect to the container:
1. **Register your own components**: You can add your own implementations of various components to the container, making them available for injection throughout the application.
1. **Adjust the behavior of existing components**: You can retrieve instances of components from the container, allowing you to use them in your own code.
#### Registering Components
Registering components lets you override nearly every aspect of how Parlant works. You can access the container during its registration phase by passing a `configure_container` hook to the server.
This hook accepts a baseline state of the container, and returns a modified version of it before the server starts.
```python
import asyncio
import parlant.sdk as p
async def configure_container(container: p.Container) -> p.Container:
# Register your own components here
# ...
return container
async def main():
async with p.Server(
configure_container=configure_container,
) as server:
# Your logic here
...
```
#### Adjusting Existing Components
If you want to adjust the behavior of built-in components, you can retrieve them from the container and modify their behavior. This is useful for debugging or extending existing functionality without replacing the entire component.
This hook is called `initialize_container`, and it allows you to modify components within the container after all of the classes have been registered and determined—but before the server actually starts to use them.
This hook accepts the final state of the container, and returns `None`, as the container is only provided for _accessing_ registered components.
```python
import asyncio
import parlant.sdk as p
async def initialize_container(container: p.Container) -> None:
# Register your own components here
# ...
return container
async def main():
async with p.Server(
configure_container=configure_container,
) as server:
# Your logic here
...
```
## Open for Extension
If you read or debug Parlant code, you'll come across many different types of components within the engine. Using the configuration and initialization hooks, you now know how to access them and extend, modify, or completely override their implementations as needed.
#### Common Use Cases for Extensions
1. Overriding the no-match behavior of canned responses. This is actually documented here: [Canned Responses](https://parlant.io/docs/concepts/customization/canned-responses#no-match-responses).
1. Wrapping any engine component to add logging, monitoring, or other cross-cutting concerns.
1. Overriding the way particular guidelines are evaluated. For example, if they are simple and you have enough data, you can evaluate them with custom-trained BERT models instead of going through an LLM.
1. Overriding the entire message generation component, allowing you to leverage Parlant's guideline matching and tool execution, but using your message generation logic.
But there's much more you can do. The engine is designed to be flexible and extensible, so you can adapt it to your specific needs without modifying the core codebase.
+58
View File
@@ -0,0 +1,58 @@
# Enforcement & Explainability
Let's dive into how Parlant enforces the conversation model consistently and provides visibility into your agent's situational understanding and decision-making process.
In this section, you'll learn:
1. How _Attentive Reasoning Queries (ARQs)_ enforce the conversation model
1. How to use ARQ artifacts to troubleshoot and improve behavior
### Understanding Runtime Enforcement
During message generation, Parlant ensures guidelines are followed consistently in real-time conversations through our [Attentive Reasoning Queries](https://arxiv.org/abs/2503.03669#:~:text=We%20present%20Attentive%20Reasoning%20Queries%20%28ARQs%29%2C%20a%20novel,in%20Large%20Language%20Models%20through%20domain-specialized%20reasoning%20blueprints.) prompting method. Rather than simply adding guidelines to prompts and hoping for the best, Parlant employes explicit techniques to maximize the LLM's ability and likelihood to adhere to your guidelines.
Attentive Reasoning Queries (ARQs) are essentially structured reasoning blueprints built into prompts that guide LLMs through specific thinking patterns when making decisions or solving problems. Rather than hoping an AI agent naturally considers all important factors, ARQs explicitly outline reasoning steps for different domains—like having specialized mental checklists to go through.
What makes ARQs effective for behavioral enforcement is that they force attention on critical considerations that might otherwise be overlooked. The model must work through predetermined reasoning stages (like context assessment, solution exploration, critique, and decision formation), ensuring it consistently evaluates important constraints before taking action.
![ARQs](https://arxiv.org/html/2503.03669v1/x1.png)
**Figure:** Illustration of ARQs (taken from the [research paper](https://arxiv.org/abs/2503.03669#:~:text=We%20present%20Attentive%20Reasoning%20Queries%20%28ARQs%29%2C%20a%20novel,in%20Large%20Language%20Models%20through%20domain-specialized%20reasoning%20blueprints.))
Besides increasing accuracy and conformance to instructions, this process creates, as a byproduct, transparent, auditable reasoning paths that help maintain alignment with desired behaviors.
ARQs are flexible enough to adapt to different contexts and risk levels, with reasoning blueprints that can be tailored to specific domains or regulatory requirements. While there's some computational overhead to this more deliberate thinking process, carefully designed ARQs can beat Chain-of-Thought reasoning in both accuracy and latency.
Parlant uses different sets of ARQs for each of its components (e.g., guideline matching, tool-calling, or message composition), and dynamically specializes the ARQs to the specific entity it's evaluating, whether it's a particular guideline, tool, or conversational context.
Here's an illustrated example from the `GuidelineMatcher`'s logs:
```json
{
"guideline_id": "fl00LGUyZX",
"condition": "the customer wants to return an item",
"condition_application_rationale": "The customer explicitly stated that they need to return a sweater that doesn't fit, indicating a desire to return an item.",
"condition_applies": true,
"action": "get the order number and item name and them help them return it",
"action_application_rationale": [
{
"action_segment": "Get the order number and item name",
"rationale": "I've yet to get the order number and item name from the customer."
},
{
"action_segment": "Help them return it",
"rationale": "I've yet to offer to help the customer in returning the item."
}
],
"applies_score": 9
}
```
### Explaining and Troubleshooting Agent Behavior
Message generation in Parlant goes through quite a lot of quality assurance. As mentioned above, ARQs produce artifacts that can help explain how the agent interpreted circumstances and instructions.
When you run into issues, you can inspect these artifacts to better understand why the agent responded the way it did, and whether it correctly interpreted your intentions.
Over time, this feedback loop helps you build more precise and effective sets of guidelines.
![Explainability in Parlant](https://parlant.io/img/explainability.gif)
@@ -0,0 +1,297 @@
# Canned Responses
Canned responses provide you with precise control over your Parlant agent's responses.
The [concept of canned responses](https://en.wikipedia.org/wiki/Canned_response) comes from real-world contact centers, where they're widely used to ensure that agents communicate with customers in a consistent, accurate, and brand-aligned manner.
By restricting to a pre-defined and pre-approved set of responses, you ensure your agent communicates with a consistent tone, style, and accuracy, aligning perfectly with your brand voice and service protocols while **completely eliminating the risk of even subtle unwanted or hallucinated outputs**.
Canned responses function like a hand of cards: given a set of choices you provide to your agent, it selects the most appropriate "card" that best matches the required response based on the conversation context.
![Cartoon1](https://parlant.io/img/utterance_cartoon_1.png)
![Cartoon2](https://parlant.io/img/utterance_cartoon_2.png)
## A Practical Example
#### Without Canned Responses
Using LLM-generated (token-by-token) responses.
> **Customer:** Do you have it in stock?
>
> **Agent:** Yes, we've got this item in stock! Let me know if you need any help finding it.
#### With Canned Responses
```
# Draft message: "Yes, we've got this item in stock! Let me know if you need any help finding it."
#
# Available templates:
# - ...
# - "Hey, {{std.customer.name}}! What help do you need today?"
# - ...
# - "No, sorry, we've just sold the last ones. Would you like to see something similar?"
# - "Yep, we have it. Should I add it to your cart?"
# - ...
```
> **Customer:** Do you have it in stock?
>
> **Agent:** Yep, we have it. Should I add it to your cart?
## How Canned Responses Work
Under the hood, canned responses work in a 4-stage process:
1. The agent drafts a fluid message based on its current situational awareness (interaction, guidelines, tool results, etc.)
2. The engine retrieves the most relevant canned response templates, based on the draft message
3. The engine renders the candidate templates, using tool-provided field substitutions where applicable
4. Based on the draft message, the agent selects the most fitting canned response out of the provided candidates
```mermaid
flowchart LR
A[Draft Fluid Message] --> B[Retrieve Most Relevant Response Templates] --> C[Render Templates] --> D[Select Canned Response]
```
## Composition Modes
Parlant agents can use one of several **composition modes** in their responses. These composition modes offer varying levels of restriction on the agent's outputs, as well as the manner in which it uses your canned responses.
| Mode | Description | Use Cases |
|------|-------------|-----------|
| **Fluid** | The agent prioritizes selecting from canned responses if a good match can be found, but falls back to default message generation otherwise. | **(A)** Staying mostly fluid, but controlling specific situations and responses where applicable<br/>**(B)** Prototyping an agent, generating fluid recommendations for additional utterances as you go |
| **Composited** | The agent will only use the canned response candidates to alter the generated draft message so as to mimic the style of the retrieved candidates | Brand-sensitive use cases where tone of voice is important to maintain |
| **Strict** | The agent can only output responses from the provided ones. If no matching response exists, the agent will send a customizable no-match message. | High-risk settings that cannot afford even the most subtle and infrequent hallucinations |
> **Tip:**
> If you have a high-risk use case and are apprehensive about deploying GenAI agents to your customers, we recommend starting out with strict mode. Parlant is flexible and will allow you to easily transition to more fluid modes when you're ready. You will still maintain and utilize all other aspects of your conversation model as you switch between composition modes.
### Setting an Agent's Composition Mode
You just need to pass the right `composition_mode` when creating your agent:
```python
await server.create_agent(
name="My Agent",
description="An agent that uses canned responses",
composition_mode=p.CompositionMode.STRICT, # or FLUID or COMPOSITED
)
```
## Creating Canned Responses
Here's how you can create canned responses for your agent:
```python
await agent.create_canned_response(template=TEXT)
```
#### Journey-Scoped Responses
You can also add **journey-scoped** responses. These are responses that are only available when a specific journey is active. Scoping your canned responses to journeys is useful for narrowing down the set of responses to choose from, increasing the chances of choosing the desired response.
To do so, just call the `create_canned_response` method on a specific journey instance instead of the agent:
```python
await journey.create_canned_response(template=TEXT)
```
#### Preamble Responses
Parlant's employs multiple techniques to enhance the conversational user-experience by leveraging the principle of [perceived performance](https://en.wikipedia.org/wiki/Perceived_performance#:~:text=Perceived%20performance%2C%20in%20computer%20engineering%2C%20refers%20to%20how,The%20concept%20applies%20mainly%20to%20user%20acceptance%20aspects.). One of these techniques is the use of **preamble responses**.
Parlant agents often send preamble responses (such as _"Got it."_, _"Understood."_, or _"Let me look into that"_) to acknowledge the customer's input while it's working on generating a detailed, accurate response.
Normally, these preamble responses are automatically generated by the agent according to the context, but you can also create custom preamble responses for the agent to choose from.
To create a canned preamble response, just add the `preamble()` tag in your canned response creation:
```python
await agent.create_canned_response(
template="Sure thing.",
tags=[p.Tag.preamble()],
)
```
## Template Syntax
Canned responses are defined using **templates**. Templates are strings that can contain static text, as well as dynamic fields that will be substituted with actual values when the response is selected.
### Standard Fields
Use standard fields (using the `std.` prefix) to display dynamic information from the conversation context:
#### Available values
1. `std.customer.name`: String; The customer's name (or `Guest` for a non-registered [customer](https://parlant.io/docs/concepts/entities/customers))
2. `std.agent.name`: String; The agent's name
3. `std.variables.NAME`: Any; The content of a variable named `NAME`
4. `std.missing_params`: List of strings; Contains the names of missing tool parameters (if any) based on [Tool Insights](https://parlant.io/docs/concepts/customization/tools#tool-insights-and-parameter-options)
#### Example
```python
await agent.create_canned_response(
template="Hi {{std.customer.name}}, Yes, this product is available in stock."
)
```
### Generative Fields
If you refer to a field with a `generative.` prefix, the LLM will auto-infer and substitute the value based on its name and the surrounding context. This is a great way to introduce controlled, localized generation into strict templates.
#### Example
```python
await agent.create_canned_response(
template="Can I ask why you'd like to return {{generative.item_name}}?"
)
```
### Tool/Retriever-Based Fields
Canned responses can refer to fields coming from tool and retriever results. These fields must be specified in the `canned_response_fields` property of a `ToolResult` or a `RetrieverResult`.
This is one of the most useful field types as they can introduce truly dynamic data into your canned responses.
> **Warning: The Crucial Role of Fields in Avoiding Consequential Hallucinations**
>
> There's another great benefit to using tool-based fields in your canned responses. When retrieving candidate responses, the engine will also look at the `canned_response_fields` to determine relevance.
>
> Responses that refer to fields that aren't present in the context will never be selected, even if they're similar to the draft message. This is important, as it helps to ensure that the responses generated by the agent are grounded in the actual data available.
>
> For example, when using the strict composition mode, your agent could never output a message referring to a `{{successful_transaction.id}}` if the `successful_transaction` field was not provided by a successfully-run tool call. In other words, if you coordinate your responses and tools correctly, you can ensure your agent never hallucinates misleading responses about data or state.
#### Example
```python
@p.tool
def get_account_balance(context: p.ToolContext) -> p.ToolResult:
balance = 1234.5
return p.ToolResult(
# Note that you must still provide the result in the `data` field,
# as this is what will inform the agent when evaluating guidelines,
# calling tools, as well as when generating the draft message.
data={f"Account balance is {balance}"},
# Here you provide dynamic values specifically for template field substitution
canned_response_fields={"account_balance": balance},
)
```
And this is how your response template would refer to this field:
```python
await agent.create_canned_response(template="Your current balance is {{account_balance}}")
```
## Returning Full Responses from Tools
Tools can also return full canned responses for consideration. This is useful when you want to generate a complete response based on the tool's output, rather than just providing data for field substitution. It is usually particularly relevant for complex Q&A retrieval scenarios.
```python
@p.tool
def get_answer(context: p.ToolContext, question: str) -> p.ToolResult:
answer = "The answer to your question is...."
return p.ToolResult(
data=answer,
# Make the answer available as a complete canned response candidate
canned_responses=[answer],
)
```
## Optimizing Response Selection
Let's look at how you can ensure that your agent selects the right canned response.
#### Controlling the Draft Message
Because of how the selection process works, the first step to getting the right response delivered is to ensure that the draft message is generated as closely as possible to your desired response. You can achieve this using all of the standard control mechanisms, such as guidelines, journeys, tools, glossary terms, and agent description.
This means you'll need to keep a close eye on what drafts are being generated prior to response selection. You can inspect the generated draft message in the integrated UI to see what the agent wanted to say.
![View canned response draft](https://parlant.io/img/utterance_draft_demo.gif)
#### Ensuring the Right Candidates Are Retrieved
Next, you need to ensure that your desired response templates are retrieved by the engine as candidates for selection.
Sometimes, the response itself is close enough to the draft message to appear in the candidate list. But not always—especially if its template contains field substitutions which make semantic similarity comparisons harder.
For this, you can use **signals** when creating canned responses. Signals are a way to tell your agent, "This response is a good match for these drafts." Each signal is essentially a draft message example. When retrieving candidate responses, the engine will also look at these signals to find determine relevance, so that if a response has a signal that's really close to the draft message, it will be retrieved as a candidate even if the response itself is quite different in form.
Here's how you can add signals to your canned responses:
```python
await agent.create_canned_response(
template="Yes, we've got this item in stock! Let me know if you need any help finding it.",
signals=["We do have it in stock", "We do! Do you need help finding it?"],
)
```
## The Flexibility of Jinja2
Response templates integrate with the Jinja2 templating engine, enabling more dynamic formatting, substitution filters, as well as list processing. You can learn more advanced syntax on the [Jinja2 documentation site](https://jinja.palletsprojects.com/en/stable/).
#### Example
```python
@p.tool
def get_pizza_toppings(context: p.ToolContext) -> p.ToolResult:
toppings = ['olives', 'peppers', 'onions']
return p.ToolResult(
data={f"Toppings are {toppings}"},
canned_response_fields={"toppings": toppings},
)
```
```python
await agent.create_canned_response(
template="We have the following toppings {% for t in toppings %}\n- {{t}}{% endfor %}"
)
```
## No-Match Responses
When using strict composition mode, if the agent cannot find a suitable canned response for its draft, it will send a no-match response.
If you want to customize this response, there are two ways to do so:
1. Customizing the static no-match response
2. Using a custom no-match provider to dynamically generate the response according to the context
#### Static No-Match Response
This is the simplest way to customize the no-match response. You can set a static no-match response that will be used whenever the agent cannot find a suitable canned response.
```python
async def initialize_func(c: p.Container) -> None:
no_match_provider = c[p.BasicNoMatchResponseProvider]
no_match_provider.template = "My custom no-match response."
async with p.Server(
initialize_container=initialize_func,
) as server:
...
```
#### Custom No-Match Provider
If you need more flexibility, you can create a custom no-match response provider. This allows you to generate the no-match response dynamically based on the conversation context.
However, please note that `p.LoadedContext` (which gives you access to internal engine state) is subject to change in future releases, so keep in mind you may need to adjust your implementation accordingly at some point.
```python
class CustomNoMatchResponseProvider(p.NoMatchResponseProvider):
async def get_template(self, context: p.LoadedContext, draft: str | None) -> str:
# Generate a custom no-match response based on the provided context,
# such as the conversation history, draft message, guidelines, tool calls, etc.
template = "..."
return template
async def configure_func(c: p.Container) -> p.Container:
c[p.NoMatchResponseProvider] = CustomNoMatchResponseProvider()
async with p.Server(
configure_container=configure_func,
) as server:
...
```
+104
View File
@@ -0,0 +1,104 @@
# Glossary
The glossary is a fundamental part of shaping your agent's understanding of its domain. It's like your agent's professional dictionary: a set of terms specific to your business or service context.
### When to Use the Glossary
When you create an agent to handle specific tasks, it often needs to understand the unique vocabulary of your domain. For example, if your agent helps guests book rooms at the Boogie Nights hotel, it needs to know what "Boogie Nights" means in your context—in this case, that it's not just a movie title, but your hotel's name.
#### Creating Glossary Terms
Here's how to create a new glossary term:
```python
await agent.create_term(
name=TERM,
description=DESCRIPTION,
synonyms=[SYNONYM_1, SYNONYM_2, ...],
)
```
### Structure of Terms
Each glossary entry consists of three components:
> * **Term:** The word or phrase being defined
> * **Description:** What this term means in your specific context
> * **Synonyms:** Alternative ways users might refer to this term
For example:
```python
await agent.create_term(
name="Boogie Nights",
description="Our luxury beachfront hotel located in Miami",
synonyms=["BN Hotel", "The Boogie", "Boogie Hotel"],
)
```
### How Agents Use the Glossary
The glossary serves two crucial purposes in agent interactions.
First, it helps your agent understand customers better when they interact with it. When a guest says _"I'd like to stay at The Boogie,"_ the agent knows they're referring to your hotel.
Second, it helps the agent interpret your guidelines correctly. Consider the following configuration:
```python
await agent.create_guideline(
condition="the user asks about Ocean View rooms",
action="explain the Sunrise Package benefits",
)
await agent.create_term(
name="Ocean View",
description="Our premium rooms on floors 15-20 facing the Atlantic",
synonyms=["seaside rooms", "beach view"],
)
await agent.create_term(
name="Sunrise Package",
description="Complimentary breakfast and early check-in for Ocean View bookings",
synonyms=["morning special", "sunrise special"],
)
```
Here, both the condition as well as the action depend on the agent understanding what these terms mean.
If the Customer comes in and asks,
> **Customer:** I heard you have some rooms with a view to the Atlantic. What are those?
The agent can understand, based on the glossary term, that the condition _"the user asks about Ocean View rooms"_ is met, and it can then respond with the action _"explain the Sunrise Package benefits"_.
## Glossary vs Guidelines vs Agent Description
Each component serves a distinct purpose in shaping your agent's behavior:
1. The Glossary teaches your agent "what things are". For example, _"A Club Member is a guest who has stayed with us more than 5 times."_ You can have as many terms as you want.
1. Guidelines teach your agent "how to act in situations". For example, _"When speaking with Club Members, acknowledge their loyalty status."_ You can have as many guidelines as you want.
1. Agent Description provides overall context and personality. For example, _"You are a helpful hotel booking assistant for Boogie Nights."_ The agent's description is static and limited.
Think of it this way: the glossary builds your agent's vocabulary, guidelines shape its behavior, and the agent description sets its overall context, role, personality and tone.
## Glossary vs Tools
While both glossary terms and tools help your agent understand your domain, they serve fundamentally different purposes. The glossary provides static knowledge, while tools enable dynamic data access.
Consider a hotel booking scenario:
**Glossary Term:**
> * **Term:** Club Member
> * **Description:** A guest who has stayed with us more than 5 times
> * **Synonyms:** loyal guest, regular guest
**Tool:**
`check_member_status(user_id) # Returns current stay count and benefits`
The glossary term provides a consistent definition of what a Club Member is, while the tool can check a specific user's actual status in your database. Similarly:
**Glossary Term:**
> * **Term:** Ocean View Room
> * **Description:** Premium rooms on floors 15-20 facing the Atlantic
> * **Synonyms:** seaside room, beach view
**Tool:**
`check_room_availability(room_type, dates) # Returns current availability and rates`
The glossary helps your agent understand what an Ocean View Room is, while the tool provides real-time information about specific rooms' availability and pricing.
This separation between static knowledge (glossary) and dynamic data access (tools) helps create clear, maintainable agent implementations that can handle both general inquiries and specific, data-driven interactions.
+201
View File
@@ -0,0 +1,201 @@
# Guidelines
Guidelines are a powerful customization feature. While they're quite simple in principle, there is a lot to say about them.
### What Are Guidelines?
Guidelines are the primary way to nudge the behavior of [agents](https://parlant.io/docs/concepts/entities/agents) in Parlant in a contextual and targeted manner.
They allow us to instruct how an agent should respond in specific situations, overriding its default behavior, thus ensuring that its behavior aligns with our expectations and business needs.
Guidelines allow us to shape an [agent](https://parlant.io/docs/concepts/entities/agents)'s behavior in two key scenarios:
1. When certain out-of-the-box responses don't meet our expectations
1. When we simply want to ensure consistent behavior across all interactions
> **Guidelines vs. Journeys**
>
> Journeys are the ideal way to provide a structured, step-by-step interaction flow, while guidelines are more about providing contextual nudges to the agent's behavior. Use journeys for complex interactions that require multiple steps, and guidelines for simpler, context-specific adjustments—as well as for simple, general tool-calling triggers that aren't necessarily within any particular journey.
#### Example
Suppose we have an agent that helps customers order products. By default, the agent's behavior might look like this:
> **User:** I'd like to order a new laptop.
>
> **Agent:** Sure, what are your preferences? (e.g., budget, operating system, screen size, use cases?)
But say we want to make our agent more personable by first having it ask simply whether they want Mac or Windows. We can add a guideline to ensure that this happens consistently, like so:
```python
await agent.create_guideline(
condition="The customer wants to buy a laptop",
action="First, determine whether they prefer Mac or Windows"
)
```
Resulting in a conversation like this:
> **User:** I'd like to order a new laptop.
>
> **Agent:** Sounds good. Would you prefer Mac or Windows?
> **Careful What You Wish For**
>
> Instructing an LLM is very similar to instructing a human, except that by default it has absolutely zero context of who is instructing it and the context in which the instruction is given. For this reason, when we provide guidelines, we must strive to be as clear and articulate as possible, so that the agent can follow them without ambiguity. More about this later in this page.
### The Structure of Guidelines
In Parlant, each guideline is composed of two parts: the **condition** and the **action**.
1. The **action** part describes what the guideline should accomplish. For example, "Offer a discount."
1. The **condition** is the part the specifies _when the action should take place_. For example, "It is a holiday".
```python
await agent.create_guideline(
condition="It is a holiday",
action="Offer a discount on the order"
)
```
When speaking informally about guidelines, we often describe them in _when/then_ form: When <CONDITION>, Then <ACTION>, or in this case, When it is a holiday, Then offer a discount.
> **Guideline Tracking**
>
> Once the action is accomplished in a session, Parlant will deactivate the guideline—unless it has reason to believe the action should re-apply due to a contextual shift (e.g., in the example above, if the customer starts another order).
### Using Tools
One of the foremost issues with most LLMs is their bias toward false-positives. Put simply, they are always looking to please, so they will tend to answer positively to most questions.
This becomes a huge problem when we want to ensure that an agent only performs certain actions when it has the right context or information.
For this reasons, Parlant allows us to associate [tools](https://parlant.io/docs/concepts/customization/tools) (essentially, functions) with guidelines, such that the agent would only consider calling a tool when a guideline's requisite condition is met within the interaction's current context.
Just as importantly, it also allows you to specify contextual information on *how* and *why* you want a particular tool to be called when certain circumstances hold. Here's an example:
```python
@p.tool
async def find_products_in_stock(context: p.ToolContext, query: str) -> p.ToolResult:
...
await agent.create_guideline(
condition="The customer asks about the newest laptops",
action="First recommend the latest Mac laptops",
# The guideline's action will ensure the following tool is
# called with the right query (e.g., "Latest Mac laptops")
tools=[find_products_in_stock],
)
```
## How Guidelines Work
To understand how guidelines work, we need to look briefly at Parlant's response processing pipeline.
When an agent receives a message, it goes through a response processing pipeline that involves several steps to ensure the response is aligned with the guidelines and expectations.
```mermaid
graph LR
Engine -->|Match guidelines| GuidelineMatcher
GuidelineMatcher -->|Call associated tools| ToolCaller
ToolCaller -->|Compose message| MessageComposer
MessageComposer -.->|Generated response| Engine
```
As the figure above suggests, guidelines are evaluated and matched *before* the agent composes its response.
> **Keep in Mind**
>
> This means that the agent needs to be able to evaluate and apply instructions and tool calls based on the interaction's context *before* generating the response. In other words, guidelines such as "Do X immediately after you've done Y" may not work as you expect.
### How Parlant Uses Guidelines
LLMs are a magnificent creation, built on the principle of [statistical attention](https://arxiv.org/abs/1706.03762) in text; yet, their attention span is painfully finite. When it comes to following instructions, they need help.
Behind the scenes, Parlant ensures that agent responses are aligned with expectations by dynamically managing the LLM's context to only include the relevant guidelines at each point.
```mermaid
%%{init: {'sequence': {'mirrorActors': false}}}%%
sequenceDiagram
participant Engine
participant GuidelineMatcher
participant MessageComposer
Engine ->> GuidelineMatcher: match guidelines
GuidelineMatcher -->> Engine: <guidelines>
Engine ->> MessageComposer: compose contextually guided message
MessageComposer -->> Engine: <guided message>
```
Before each response, Parlant only loads the guidelines that are relevant to the conversation's current state. This dynamic management keeps the LLM's "cognitive load" minimal, maximizing its attention and, consequently, the alignment of each response with expected behavior.
> Another important ability that Parlant employs to ensure alignment is supervising the agent's outputs before they reach the [customer](https://parlant.io/docs/concepts/entities/customers), to ensure to the utmost degree that guidelines were correctly adhered to. To achieve this, NLP researchers working on Parlant have devised an innovative prompting technique called **Attentive Reasoning Queries (ARQs)**. You're welcome to explore the research paper on [arxiv.org, Attentive Reasoning Queries: A Systematic Method for Optimizing Instruction-Following in Large Language Models](https://arxiv.org/abs/2503.03669#:~:text=We%20present%20Attentive%20Reasoning%20Queries%20%28ARQs%29%2C%20a%20novel,in%20Large%20Language%20Models%20through%20domain-specialized%20reasoning%20blueprints.).
### Managing Guidelines
Parlant is built to make guideline management as simple as possible.
Often, guidelines are added when business experts request behavioral changes in the agent. Developers can use Parlant to make those changes, iterating quickly and reliably, at the pace of the business experts they're working with.
Here's a practical example. When Sales requests: "The agent should first ask about the customer's needs and pain points before discussing our solution," implementing this feedback takes just a minute by adding the following:
```python
await agent.create_guideline(
condition="The customer has yet to specify their current pain points",
action="Seek to understand their pain points before talking about our solution"
)
```
Once added, Parlant takes care of the rest, automatically ensuring this new guideline is followed consistently across all relevant conversations, without degrading your agent's conformance to other guidelines.
### Formulating Guidelines
Think of an LLM as a highly knowledgeable stranger who's just walked into your business. They might have years of general experience, but they don't know your specific context, preferences, or way of doing things. Yet, this stranger is eager to help and will always try to—even when uncertain.
This is where guidelines come in. They're your way of channeling this endless enthusiasm and broad knowledge into focused, appropriate responses.
But specifying effective guidelines is a bit of an art—just like it is with people.
#### The Art of Guidance
Consider a customer service scenario. As a very naive example, we might be tempted to have:
**DON'T**
> * **Condition:** Customer is unhappy
> * **Action:** Make them feel better
While well-intentioned, this is an example of a guideline that is just too vague. The LLM might interpret this in countless ways, from offering discounts it can't actually provide to making jokes that might be inappropriate for your brand. Instead, consider:
**DO**
> * **Condition:** Customer expresses dissatisfaction with our service
> * **Action:** Acknowledge their frustration specifically, express sincere empathy, and ask for details about their experience so we can address it properly.
Notice how this guideline is both specific and bounded.
**DON'T**
> * **Condition:** Customer asks about products
> * **Action:** Recommend something they might like
**DO**
> * **Condition:** Customer asks for product recommendations without specifying preferences
> * **Action:** Ask about their specific needs, previous experience with similar products, and any particular features they're looking for before making recommendations
#### Finding the Right Balance
In principle, we're looking for guidelines that are "just right"—neither over nor under specified. Consider these iterations for a technical support agent:
**DON'T**
Too vague:
> * **Condition:** Customer has a technical problem
> * **Action:** Help them fix it
**DON'T**
Too rigid:
> * **Condition:** Customer reports an error message
> * **Action:** First ask for their operating system version, then their browser version, then their last system update date
**DO**
Just right:
> * **Condition:** Customer reports difficulty accessing our platform
> * **Action:** Express understanding of their situation, ask for key details about their setup (OS and browser), and check if they've tried some concrete troubleshooting steps
Remember, LLMs will usually take your guidance quite literally. If you tell your agent to "always suggest premium features," it might do so even when talking to a customer who's complaining about pricing. Always try to consider the broader context and potential edge cases when formulating your guidelines. It'll pay off in less changes and troubleshooting down the line.
**If in doubt, prefer to err on the side of vagueness.** The goal of Agentic Behavior Modeling isn't to script out every possible interaction but to provide clear, contextual guidance that shapes the LLM's natural generalization abilities into reliable, appropriate responses for your specific use case.
+337
View File
@@ -0,0 +1,337 @@
# Journeys
There are many use cases where you want your agent to follow a specific flow of conversation, such as booking a trip, troubleshooting an issue, or otherwise guiding a user through a conversational process in the intended manner.
In Parlant, this can be achieved easily and reliably using **Journeys**.
#### Journey Structure
Journeys have 4 important components:
1. **Title:** A short, descriptive name for the journey, to differentiate it from other journeys.
1. **Conditions:** These contextual queries determine when a journey should be active.
1. **Description:** This lets you describe the nature of the journey, including motivating or orientating notes, if needed.
1. **States & Transitions**: A state diagram that communicates to the agent what the ideal flow is.
> **Balancing Rigidity vs. Flexibility**
>
> In traditional conversational frameworks, flows are rigidly defined, with each state and transition being strictly followed to the letter.
>
> While this type of approach is easy to reason about and implement, it very often leads to frustrating user experiences when the agent is unable to adapt to the customer's interaction patterns. This "one-size-fits-all" approach doesn't account for the nuances of human conversation—leading to user disengagement and dissatisfaction, and ultimately resulting in an unused agent.
>
> Parlant implements a "lessons learned" approach, allowing agents to traverse the journey's states in a more natural way. They can choose to skip states, revisit previous states, or even jump ahead to later states in an adaptive manner.
>
> As such, journeys are not meant to be followed rigidly, but rather to serve as a guiding framework for the agent. The agent will strive to follow the flow as strictly as it can, while still maintaining an adaptive approach toward the customer's interaction patterns.
## A Worked Example
Consider the following example for a journey in a travel agent:
> * **Title:** Book Flight
> * **Conditions:** The customer requested to book a flight
> * **Description:** This journey guides the customer through the flight booking process.
```mermaid
%%{init: { "theme": "forest" }}%%
stateDiagram-v2
direction LR
state "Where to?" as A
state "Dates?" as B
state "Load destinations" as Ca
state "Suggest" as Cb
state "Confirm" as E
state "Book" as F
state "Provide ticket" as G
[*] --> A
A --> Ca: Don't know
Ca --> Cb
A --> B: Destination provided
Cb --> B: Destination selected
B --> E
E --> F: Yes
E --> [*]: No
F --> G
G --> [*]
style Ca fill:#ffeecc,stroke:#333,stroke-width:1px
style F fill:#ffeecc,stroke:#333,stroke-width:1px
```
This journey will be activated when the customer asks to book a flight. The agent will then strive to follow the flow while maintaining an adaptive approach at the pace of the customer, yet ensuring that all necessary information is collected.
#### Implementing the Journey
Before we learn more about how journeys work, let's look at how we would implement the journey above:
```python
async def create_book_flight_journey(agent: p.Agent):
journey = await agent.create_journey(
title="Book Flight",
conditions=["The customer requested to book a flight"],
description="This journey guides the customer through the flight booking process.",
)
t1 = await journey.initial_state.transition_to(chat_state="Ask if they have a destination in mind")
# Branch out based on the customer's response
t2 = await t1.target.transition_to(condition="They do", chat_state="Get dates of travel")
t3a = await t1.target.transition_to(condition="They don't", tool_state=load_popular_destinations)
t3b = await t3a.target.transition_to(chat_state="Recommend a destination")
# Merge back to the main path after choosing a destination.
# This is done by transitioning into an existing state node.
await t3b.target.transition_to(state=t2.target, condition="Destination selected")
t4 = await t2.target.transition_to(chat_state="Confirm details")
t5a = await t4.target.transition_to(tool_state=book_flight)
t5b = await t5a.target.transition_to(chat_state="Provide ticket details")
```
## States and Transitions
A journey is modeled after a state diagram, which is a directed graph in which each node represents a **state** and each edge represents a **transition** (which may be associated with a condition).
```mermaid
stateDiagram-v2
direction LR
state "CHAT STATE" as A
state "TOOL STATE" as B
state "CHAT STATE" as C
state "CHAT STATE" as D
state "FORK STATE" as E
state "CHAT STATE" as F
state "CHAT STATE" as G
[*] --> A: INITIAL
A --> B: CONDITIONAL
A --> C: CONDITIONAL
B --> D: DIRECT
C --> E: DIRECT
D --> E: DIRECT
E --> F: CONDITIONAL
E --> G: CONDITIONAL
F --> [*]: END
G --> [*]: END
style B fill:#ffeecc,stroke:#333,stroke-width:1px
```
#### States
1. **Chat States:** While in this state, the agent will chat with the customer while being guided by the state's action. The agent may spend multiple turns in this state, until it decides to transition to another state.
```python
t = await state.transition_to(chat_state=CONVERSATIONAL_INSTRUCTION)
```
2. **Tool States:** In this state, the agent will call an external tool to perform an action and load its result into the context. A tool state must be followed by a chat state, which will usually be used to present the tool's result to the customer.
```python
t = await state.transition_to(tool_state=TOOL)
```
```python
t = await state.transition_to(tool_state=TOOL, tool_instruction=OPTIONAL_HINT_ON_HOW_TO_USE_TOOL)
```
> **Transitioning from Tool to Chat**
>
> When transitioning from a tool state to a chat state, the agent will automatically load the tool's result into the context, so you can use it in the chat state's action. Note that a tool state cannot transition to another tool state; it must always be followed by a chat state.
>
> This is by design, as tool usage can incur noticeable latency in agentic applications. Instead of using sequential tool states, you should use a single tool state to perform all necessary actions, and then follow it with a chat state to present the results to the customer.
#### Transitions
1. **Direct Transitions:** These transitions should always be taken. They move the conversation forward without branching.
2. **Conditional Transitions:** These transitions are only taken if/when their associated condition is met.
```python
t = await state.transition_to(chat_state=CONVERSATIONAL_INSTRUCTION, condition=CONDITION)
```
```python
t = await state.transition_to(tool_state=TOOL, condition=CONDITION)
```
In most cases, you'd be using the `transition_to()` overload that takes a `chat_state` or `tool_state` argument, which will automatically create the transition's target state for you. However, you can also use the `transition_to()` overload that takes a `state` argument, which allows you to transition to an existing state node in the journey.
```python
t = await state.transition_to(state=EXISTING_STATE)
```
```python
t = await state.transition_to(state=EXISTING_STATE, condition=CONDITION)
```
> **Combining Conditional and Direct Transitions**
>
> If a state has a conditional transition to another state, it cannot also have a direct transition coming out of it. This is because the engine would not be able to logically determine which transition to take when the condition is met. The SDK enforces this rule.
#### Fork States
Journeys also support a special kind of state, called a **fork state**.
In this state, the agent will evaluate conditions and branch the conversation flow accordingly. While, strictly speaking, such branching can be modeled without fork states, they are sometimes a useful modeling tool for keeping the conversation flow clear, explicit, and organized.
```python
fork = await state.fork()
t1 = await fork.transition_to(chat_state=CONVERSATIONAL_INSTRUCTION, condition=CONDITION_1)
t2 = await fork.transition_to(chat_state=CONVERSATIONAL_INSTRUCTION, condition=CONDITION_2)
t3 = await fork.transition_to(tool_state=TOOL, condition=CONDITION_3)
```
> **Visualizing Your Journey**
>
> Building a state diagram in code can sometimes be a bit confusing. It's often useful to visualize the journey as you build it, to ensure that the flow is clear, logical, and as you intend. Here's how it's done:
>
> 1. Visit `http://localhost:8800/journeys` in your browser.
> 2. Copy the ID of the journey you want to visualize.
> 3. Visit `http://localhost:8800/journeys/<JOURNEY_ID>/mermaid` in your browser, replacing `<JOURNEY_ID>` with the ID you copied.
> 4. Copy the generated Mermaid diagram code.
> 5. Paste it into a [Mermaid live editor](https://mermaid.live/) to visualize the journey.
## Journey vs. Task Automation
If you look at how the engine works with journeys, it means that journeys should not be used to guide the model on how to automate tasks. Instead, journeys are used by the agent to self-orientate and guide the conversation flow according to your preferences.
This is a good time to recall the importance of separating **business logic** from **conversation logic**. The former is best handled by custom, [dedicated tools](https://parlant.io/docs/concepts/customization/tools) (which may or may not use LLMs internally), while the latter is best handled by the conversational engine.
#### Do's and Don'ts
**DON'T**
The following is ***not*** a valid journey, as it does not represent a conversation flow but rather a task automation flow.
```mermaid
stateDiagram-v2
direction LR
state "Find user ID" as A
state "Load personal preferences" as B
state "Send email" as C
[*] --> A
A --> B
B --> C: Email notifications enabled
B --> [*]: Email notifications disabled
style A fill:#ffeecc,stroke:#333,stroke-width:1px
style B fill:#ffeecc,stroke:#333,stroke-width:1px
style C fill:#ffeecc,stroke:#333,stroke-width:1px
```
**DO**
The following is a valid journey, as it represents a conversation protocol that guides the customer through a process.
```mermaid
stateDiagram-v2
direction LR
state "Ask for order number" as A
state "Get order details" as B
state "Process refund" as C
state "Transfer to human" as D
[*] --> A
A --> B
B --> C: Eligible for refund
B --> D: Not eligible for refund
C --> [*]
D --> [*]
style B fill:#ffeecc,stroke:#333,stroke-width:1px
```
## Context Management
LLMs are a magnificent creation, built on the principle of [statistical attention](https://arxiv.org/abs/1706.03762) in text; yet, their attention span is painfully finite. When it comes to following instructions, they need help.
Behind the scenes, Parlant ensures that agent responses are aligned with expectations by dynamically managing the LLM's context to only include the relevant journeys at each point.
It does this using the `GuidelineMatcher`, essentially matching the current conversation context with the relevant journeys' conditions—which, behind the scenes are basically observational (non-actionable) guidelines.
```mermaid
%%{init: {'sequence': {'mirrorActors': false}}}%%
sequenceDiagram
participant Engine
participant GuidelineMatcher
participant JourneyStore
participant MessageComposer
Engine ->> GuidelineMatcher: match guidelines
GuidelineMatcher -->> Engine: <guidelines>
Engine ->> JourneyStore: get journeys for matched conditions
JourneyStore -->> Engine: <journeys>
Engine ->> GuidelineMatcher: match journey states
GuidelineMatcher -->> Engine: <journey states>
Engine ->> MessageComposer: <journey states, guidelines>
MessageComposer -->> Engine: <well-guided message>
```
Before each response, Parlant only loads the guidelines and journeys that are relevant to the conversation's current state. This dynamic management keeps the LLM's "cognitive load" minimal, maximizing its attention and, consequently, the alignment of each response with expected behavior.
> **Latency Optimizations**
>
> This back and forth approach is implemented in an optimized algorithm that minimizes response latency.
>
> The engine first tries to predict which journeys will be activated based on the current conversation context. Given this prediction, it attempts to match the relevant journeys' states in parallel to guideline matching, shaving seconds off the response latency.
>
> Only when this prediction fails (i.e., when other journeys were activated) does it incur the extra step to match their states, as well.
## Journey-Scoped Guidelines
You can add journey-scoped [guidelines](https://parlant.io/docs/concepts/customization/guidelines) that can only be activated when their dependent journeys are also active. At all other times, these guidelines would be ignored.
Using journey-scoped guidelines is the recommended way to handle digressions from the journey's main flow in deliberate ways. It also helps you maintain a clean and organized conversation model, ensuring that certain guidelines are only evaluated and activated in their intended context.
> **Instruction Precedence**
>
> Please note that, in general, Parlant agents give more weight to guidelines than to journey states, as guidelines are treated as more specific behavioral overrides. This means that, if a guideline is matched, it will tend to take precedence over the active journey states.
```python
@p.tool
async def transfer_to_human_agent(context: p.ToolContext) -> p.ToolResult:
...
guideline = await journey.create_guideline(
condition="the customer says they're unable to pay"
action="connect them with a human agent",
tools=[transfer_to_human_agent],
),
```
> **Learn More**
>
> To learn more about guidelines, check out the [Guidelines](https://parlant.io/docs/concepts/customization/guidelines) page.
## Journey-Scoped Canned Responses
You can also attach canned responses to journeys, scoping them to those journeys such that they will only be considered when their dependent journeys are active.
```python
await journey.create_canned_response(
template="What destination are you interested in?",
)
await journey.create_canned_response(
template="I'm sorry, but I can't assist with that right now. Shall we go on with booking your flight?",
)
```
#### State-Scoped Canned Responses
You can also associate specific canned responses with specific states within a journey.
There are two modes for state-scoped canned responses: **Explicit Consideration** and **Exclusive Consideration**.
1. **Explicit Consideration:** In this mode, the agent will ensure that the associated responses are always considered for selection when in that state. This is done by creating the canned response under the `journey` or `agent` objects.
```python
await state.transition_to(
chat_state="Ask if they have a destination in mind",
canned_responses=[
await journey.create_canned_response(
template="What destination are you interested in?",
),
],
)
```
2. **Exclusive Consideration:** In this mode, the agent will only consider the associated responses when in that state. It won't use these responses at any other time. This is done by creating the canned response under the `server` object.
```python
await state.transition_to(
chat_state="Ask if they have a destination in mind",
canned_responses=[
await server.create_canned_response(
template="What destination are you interested in?",
),
],
)
```
> **Learn More**
>
> To learn more about canned responses, check out the [Canned Responses](https://parlant.io/docs/concepts/customization/canned-responses) page.
@@ -0,0 +1,180 @@
# Relationships
Defining how **guidelines** and **journeys** relate to each other is a powerful (and advanced) part of behavior modeling.
#### Background & Motivation
Back in the day, our team was building a pizza-sales agent, which had the following guidelines (among others):
```python
offer_pepsi_instead_of_coke = await agent.create_guideline(
condition="The customer wants a coke",
action="Tell them we only have Pepsi",
)
handoff_if_upset = await agent.create_guideline(
condition="The customer is becoming upset",
action="Apologize and tell them you will transfer them to a manager",
tools=[handoff_to_human_manager],
)
```
This initially worked well, until we encountered the following scenario:
> **Agent:** Do you want anything to drink with your order?
>
> **User:** A coke please
>
> **Agent:** I'm sorry, we only have Pepsi. Would you like that instead?
>
> **User:** Wait, what? I hate Pepsi. Why the hell don't you have coke?
>
> **Agent:** I'm sorry for this inconvenience. Let me transfer you to a manager. Meanwhile, can I offer you a Pepsi?
>
> **User:** Are you taking the piss out of me?
The agent's response was definitely not what we wanted, as it can come across as sarcastically hostile. But the poor AI agent was only following the guidelines we had given it, based on the conditions of the guidelines we had set.
And here's the thing. You will find that managing instructions is not just a technical challenge, it's also a human modeling challenge. We must consider how our instructions relate to each other to an automatic agent who's expected to take them quite literally. How they should relate to each other in different contexts, especially in more nuanced situations, is something that ultimately only we can decide.
In the case above, we wanted to ensure that the second guideline would be prioritized *over* the first one. We needed a priority relationship, which today, in Parlant, can be expressed quite simply, as follows:
```python
await handoff_if_upset.prioritize_over(offer_pepsi_instead_of_coke)
```
## Relationship Kinds
While these relationships might sound complex at first, they give you a ton of power as a modeler, making you much more capable of generating precise responses, consistently.
We recommend reviewing these relationships briefly to understand their purpose.
#### Relationship Types
These are the supported relationships. Each relationship is between a _source_ (notated **S**) and a _target_ (notated **T**).
Click on a relationship type to learn more about it.
- [Entailment](https://parlant.io/docs/guidelines/relationships#entailment): When **S** is activated, **T** should always be activated
- [Priority](https://parlant.io/docs/guidelines/relationships#priority): When both **S** and **T** are activated, only **S** should be activated
- [Dependency](https://parlant.io/docs/guidelines/relationships#dependency): When **S** is activated, deactivate it unless **T** is also activated
- [Disambiguation](https://parlant.io/docs/guidelines/relationships#disambiguation): When **S** is activated and two or more of the targets **T ∈ \{T₁, T₂, ...\}** are activated, ask the customer to clarify which action they want to take
### Entailment
> When **S** is activated, **T** should always be activated
```python
await source.entail(target)
```
To understand the need for entailment, we first need to understand how Parlant chooses which guidelines activate for an agent when it's about to say something to the customer.
Basically, Parlant examines the session at its current state, and asks questions about it: "Is this guideline relevant now?", "Is that guideline relevant now?".
To do this, it primarily tests the guidelines' _conditions_.
This would seemingly work well by itself, until you consider two guidelines of the following form:
> * **Guideline A:** When X, Then Y
> * **Guideline B:** When Y, Then Z
Now imagine a situation where, looking at a session, we determine that _X_ does in fact apply, but _Y_ doesn't. With the naive logic above, we would have only fed the agent with the guideline to do _Y_.
But when we step back and analyze this case, we know that the agent is just about to do _Y_, which means that, according to the guidelines we have installed, _Z_ should also apply.
That is what entailment accomplishes: requiring that whenever _A_ is activated, _B_ is also activated.
### Priority
> When both **S** and **T** are activated, only **S** should be activated
```python
await source.prioritize_over(target)
```
Priority can be used for multiple use cases. The two most common ones are:
1. Creating mutually exclusive guidelines
1. Controlling the flow and precedence of actions within the conversation
#### On Controlling Precedence
You may have two guidelines that happen to be activated at the same time, such as:
> * When the customer wants to make a transaction, Then guide them through the process to its completion
> * When the customer has less than $1,000 in their account, Then offer savings plans
You may find that the guidelines above activate simultaneously when, for example, account balance details are introduced into the session while the user is in the process of submitting a transaction.
To ensure that savings plans are offered—but with good timing, only once the transaction is completed—you can prioritize completing the transaction over offering savings plans. Once the transaction is completed, the savings-related guideline may be activated.
### Dependency
> When **S** is activated, deactivate it unless **T** is also activated
```python
await source.depend_on(target)
```
A dependency helps you ensure that a guideline is only activated if other baseline conditions also hold.
The most common use cases is to ensure that more specific conditions are activated only in the proper baseline contexts.
#### Contextualizing Specific Conditions
When you're building flows, you can address specialized or edge-case scenarios by making them dependent on the flow baseline guideline. For example:
##### Baseline Guideline
> When the customer wants to return an order, Then help them complete the return process
##### Dependent Guidelines
> * When the customer isn't able to provide the order number, Then load up their last order's items and ask them to confirm if that is their order
> * When the customer specified the exact order number, Then load up that order's items and ask them to confirm if that is their order
By making these guidelines dependent on the baseline guideline, you can ensure that their evaluation is always performed in the right context.
### Disambiguation
> When **S** is activated and two or more of the targets **T ∈ \{T₁, T₂, ...\}** are activated, ask the customer to clarify which action they want to take
```python
await source.disambiguate([target_1, target_2, ...])
```
You may have a situation where between two (or more) competing guidelines where some or all of which are activated at the same time due to ambiguity, leading to instruction following confusions.
For example, if a customer sent the message _"What are my limits?"_ to a banking agent, and you had the following guidelines, each of which was optimistically activated according to the engine's interpretation:
> * When the customer is inquiring about their ATM limits, Then fetch the data from their account profile
> * When the customer is inquiring about their credit card's limits, Then fetch them from the card provider
To clarify the customer's intent, you could add an [observational guideline](https://parlant.io/docs/guidelines/relationships#observational-guidelines) to disambiguate between the two actions:
```python
ambiguous_limits = await agent.create_observation(
condition="The customer is inquiring about limits but it isn't clear which kind",
)
await ambiguous_limits.disambiguate([fetch_atm_limits, fetch_credit_card_limits])
```
> **Info: Observations**
>
> `Agent.create_observation()` is a shorthand for creating a guideline without an action. This guideline will still be matched in-context, but it carries no action to perform. It is useful for creating relationships between guidelines in specific scenarios, such as the example above.
## Observational Guidelines
When modeling conversational edge cases, with relationships, you may find yourself wishing to add a guideline just to establish (using its condition) that particular circumstances apply, and—only in those cases—to activate or deactivate other guidelines or journeys using relationships.
To this end, Parlant supports a special type of guideline called an **observational guideline**. This is a guideline that has no action, and is generally only used to establish that certain conditions apply, and to create relationships around them.
```python
observation = await agent.create_observation(condition=CONDITION)
```
You can then use this observation in interesting ways, such as:
1. Deactivating other guidelines by prioritizing the observation over them.
```python
await observation.prioritize_over(other_guideline)
```
2. Scoping other guidelines to only apply when the observation is active.
```python
await other_guideline.depend_on(observation)
```
And other creative uses!
+97
View File
@@ -0,0 +1,97 @@
# Retrievers
For pragmatic reasons, Parlant distinguishes between two modes of data access; namely, tools and **retrievers**.
When developing customer-facing agents, there are practically two different use cases for fetching data:
1. **Tools**: Fetching data from specific services in response to specific events, such as user requests.
2. **Retrievers**: Fetching contextual information to ground, orientate, and align the agent's knowledge with respect to the current state of the conversation. This is traditionally referred to as RAG (Retrieval-Augmented Generation).
A rule of thumb is to use **retrievers** for data that you would typically expect the agent "to know"—compared to tools, which are used for data that the agent needs to "load" or "do something with".
**Use cases for retrievers include:**
- Fetching answers to common questions
- Fetching relevant documents or information based on the current conversation context
- Fetching user-specific data to personalize the agent's responses (see also [Variables](https://parlant.io/docs/concepts/customization/variables))
> **Tip: The Response Latency Trade-Off**
>
> Because retrievers are only used to ground the agent's knowledge within the current conversation's context, they can typically be executed in parallel with the agent's other tasks (such as guideline matching, tool calling, etc.).
>
> Hence, using retrievers allows you to ground your agent's response without the added latency of guideline matching or tool calls.
## Creating a Retriever
A retriever is a function that takes a `p.RetrieverContext` and returns a `p.RetrieverResult`. The `p.RetrieverContext` contains the current conversation context, and the `p.RetrieverResult` contains the data that the retriever has fetched.
```python
async def my_retriever(context: p.RetrieverContext) -> p.RetrieverResult:
...
```
#### Simple RAG Example
Here's a simple example of a retriever that fetches documents from a DB based on the customer's last message:
```python
async def answer_retriever(context: p.RetrieverContext) -> p.RetrieverResult:
# Get the last message from the conversation
if last_message := context.interaction.last_customer_message:
# Use an embedder to convert the message into a vector
message_vector = my_embedder.embed(last_message.content)
# Fetch documents from the database based on the message vector
documents = await fetch_documents_from_db(message_vector)
return p.RetrieverResult(documents)
return p.RetrieverResult(None)
```
Alternatively, you could use an LLM to generate a query based on the entire interaction history:
```python
async def answer_retriever(context: p.RetrieverContext) -> p.RetrieverResult:
if context.interaction.messages:
# Join all messages in the conversation to create a neat context
conversation_text = "\n".join(str(msg) for msg in context.interaction.messages)
# Use an LLM to extract a user query from the conversation
if query := await my_llm.extract_user_query_from_conversation(conversation_text):
# Use an embedder to convert the query into a vector
message_vector = my_embedder.embed(query)
# Fetch documents from the database based on the query vector
documents = await fetch_documents_from_db(message_vector)
return p.RetrieverResult(documents)
return p.RetrieverResult(None)
```
#### Attaching a Retriever
To actually get an agent to use your retriever, you need to attach it in the following manner:
```python
await agent.attach_retriever(my_retriever)
```
You can also specify the retriever's ID, which is useful for debugging and logging purposes:
```python
await agent.attach_retriever(my_retriever, id="my_retriever")
```
## Retriever Result Lifespan
The lifespan of retriever results is limited to the current response; in other words, it does not persist throughout the conversation. This also helps you keep the conversation context clean and focused, while also reducing average input tokens, throughout the conversation.
## Retriever Context
Using the retriever context, you can access a number of useful properties that can help you build more sophisticated retrievers:
- `server`: The server that is currently processing the retriever request, which can be useful for accessing server-specific resources or configurations.
- `container`: The dependency-injection container that is currently being used, which allows you to access services and resources registered in the container.
- `logger`: The logger that is currently being used, which can be useful for logging debug information or errors during the retriever's execution.
- `trace_id`: A unique identifier for the agent's current response, which can be used for tracking and debugging purposes.
- `interaction`: The current interaction, which contains the conversation history and other relevant information.
- `agent`: The agent that is currently processing the interaction.
- `customer`: The customer that is currently interacting with the agent.
- `variables`: The variables that are currently set for the interaction.
+434
View File
@@ -0,0 +1,434 @@
# Tools
Parlant provides a guided approach to tool usage, tightly integrated with its guidance system.
Parlant's tool-calling approach is built from the ground up. It's probably more comprehensive than the tool-calling mechanisms you may be familiar with from most LLM APIs (including [MCP](https://modelcontextprotocol.io/introduction)). This is because it's built to enable a deep, seamless integration with its guidance-based behavior control.
### Understanding Tool Usage
In Parlant, tools are always associated with specific guidelines. A tool only executes when its associated guideline is matched to the conversation. This design creates a clear chain of intent: guidelines determine when and why tools are used, rather than leaving it to the LLM's judgment (which is rife with errors).
In Parlant, **business logic** (encoded in tools) is consciously separated from **presentation** (user interface) concerns, or the customer-facing behavior that is controlled by guidelines.
This allows you to have developers work out API logic in code, with full control—offering these tools in the "tool shed" of an agent. Then, business experts can independently define natural-language guidelines that determine when and how these tools are used, without needing to get involved with the underlying code. This separation of concerns is a key design principle in Parlant, allowing for cleaner, more maintainable systems.
#### Conversational UI vs. Graphical UI
As an analogy, you can think of Guidelines and Tools like Widgets and Event Handlers in Graphical UI frameworks. A GUI Button has an `onClick` event which we can associate with some API function to say, _"When this button is clicked, run this function."._ In the same way, in Parlant (which is essentally a Conversational UI framework) the Guideline is like the Button, the Tool is like the API function, and the association connects the two (like registering an event handler) to say, _"When this guideline is applied, run this tool."_
Here's a concrete example to illustrate these concepts:
> * **Condition:** The user asks about service features
> * **Action:** Understand their intent and consult the docs to answer
> * **Tools Associations:** `[query_docs(user_query)]`
Here, the documentation query tool only runs after the guideline instructs that we should be consulting documentation for this user interaction.
## Writing Tools
To write a tool, you need to define a function that takes a `ToolContext` as its first argument, followed by any other parameters you want to pass to the tool. The function should return a `ToolResult`.
Here's the basic structure of any tool in a Parlant agent:
```python
import parlant.sdk as p
@p.tool
async def tool_name(context: p.ToolContext, param1: str, param2: int) -> p.ToolResult:
"""Multi-line tool description.
This is readable by the agent and helps it decide if/when to run this tool.
"""
...
```
To illustrate that more concretely, here's a simple example of a tool that fetches products into the agent's context:
```python
import parlant.sdk as p
@p.tool
async def find_products(context: p.ToolContext, query: str) -> p.ToolResult:
"""Fetch products based on a natural-language search query."""
# Simulate fetching the balance from a database or API
products = await MY_DB.get_products(query=query)
return p.ToolResult(balance)
```
#### Optional Parameters
You can also define optional parameters in your tool by using the `Optional` type from the `typing` module. This allows the tool to be called without providing a value for that parameter.
```python
from typing import Optional
import parlant.sdk as p
@p.tool
async def find_products(
context: p.ToolContext,
query: str,
limit: Optional[int]
) -> p.ToolResult:
default_limit = 10
products = await MY_DB.get_products(query=query, limit=limit or default_limit)
return p.ToolResult(products)
```
## Connecting Tools to Your Agent
To allow your agent to run a tool, you need to specify the conditions under which it may be evaluated and called. As stated above, this approach helps to eliminate false-positive tool calls, which is a common problem with LLMs.
```python
@p.tool
async def my_tool(context: p.ToolContext) -> p.ToolResult:
...
await agent.create_guideline(
condition=CONDITION,
action=ACTION,
tools=[my_tool],
)
```
If you the tool itself implies the action, you can skip specifying it manually by letting Parlant figure it out from the tool's description. Here's how that would look:
```python
await agent.attach_tool(condition=CONDITION, tool=my_tool)
```
## Tool Result
The `ToolResult` is a special object that encapsulates the result of a tool call.
It has five properties you can use. While most of the time you will only use the `data` property, it's worth knowing about the others, as they enable interesting use cases: `data`, `metadata`, `control`, `canned_responses`, and `canned_response_fields`.
> **Tool Result Lifespan**
>
> Unlike many general-purpose agent frameworks, Parlant is specifically and deliberately optimized for building conversational agents. As such, its architecture optimizes the default behavior of tool-calling for a conversational application.
>
> To this end, tool results are saved in the session by default, and are therefore available for the agent reference's throughout the entire interaction session. This means that subsequent guideline matching and tool calls are automatically informed by the previous results of tools. This is useful for results that need to be referenced later, such as account balances, item IDs, or other product information.
>
> For example, if you call a tool that returns product names and IDs, and—after the customer responds by selecting a specific product—you then call another tool that takes a `product_id` parameter, the agent will be able to use the previous tool's result to fill in that parameter automatically from context. For example:
>
> ```python
> @p.tool
> async def get_products(context: p.ToolContext, query: str) -> p.ToolResult:
> products = await MY_DB.get_products(query=query)
> return p.ToolResult(data=products)
>
> @p.tool
> async def get_product_details(context: p.ToolContext, product_id: str) -> p.ToolResult:
> # The agent will be able to parameterize the right `product_id`
> # if the previous tool call was already made in the session.
> product = await MY_DB.get_product(product_id=product_id)
> return p.ToolResult(data=product)
> ```
### Tool Result Properties
Let's look at each of these properties, what they're used for, and how to use them:
#### Data
The `data` property contains the main output of the tool. This can be any JSON-serializable type, such as a string, list, or dictionary.
This is the only property of the `ToolResult` that is _always_ required, as it is the only one that the agent uses to understand the history of interaction events. Meaning, if you don't return anything in the `data` property, the agent will not be informed about your result, and it will not be able to it to navigate the interaction.
```python
# Example 1
return p.ToolResult(data="This is the result of my tool call")
# Example 2
return p.ToolResult(data={"appointments": [
{ "id": "123", "date": "2023-10-01 10:00" },
{ "id": "456", "date": "2023-10-02 11:00" },
]})
```
#### Metadata
The `metadata` property is an optional dictionary that can be used to store additional information about the tool call.
The agent is not aware of this metadata at all, but you can fetch it from the response using the REST API client. This makes it useful for sending back additional information about the response that can add value in your frontend.
A classic use case here is to return RAG information sources (e.g., URLs, document IDs, etc.) that can be used to display the source of the information in the frontend. Another one is to return image links to generated charts or other visualizations that can be displayed in the frontend.
```python
return p.ToolResult(
data=ANSWER,
metadata={ "sources": [{"url": s.url, "title": s.title} for s in ANSWER_SOURCES]},
)
```
```python
return p.ToolResult(
data="The profit margin is 20%",
metadata={ "generated_chart_url": "https://example.com/chart.png" },
)
```
> **Mind the Lifespan**
>
> Since metadata is primarily useful when accessing session events, it generally only makes sense to use it with `lifespan: "session"` (the default). If you use `lifespan: "response"`, the metadata will not be available in the session events, hence not accessible to the frontend.
#### Control
The `control` property lets you specify control directives for the agent and the engine.
There are currently two control directives you can use:
- `"mode": p.SessionMode`: This allows you to put the session in manual mode, which means the agent will not automatically generate responses. This is particularly useful for human-handoff scenarios, where you want to pause the agent's automatic responses and let a human operator take over.
- `"lifespan": p.Lifespan`: This controls how long the `ToolResult` should live. There are two options:
- `"session"`: The result will be saved and made available to the agent for the entire session. This is the default.
- `"response"`: The result will only be available for the current response. This is useful for temporary results that are not needed beyond the current response, such as reporting errors, or providing very transient information.
```python
return p.ToolResult(
data="Transferring to a human agent",
# Once this tool result is returned, the agent will not generate any more responses
# until the session is put back on automatic mode using an API call.
control={ "mode": "manual" },
)
```
```python
return p.ToolResult(
data="Encountered an error while fetching data",
# This tool result will not be saved in the session.
# The agent will only be aware of it during the current response.
control={ "lifespan": "response" },
)
```
#### Canned Responses
Tools can also return complete canned responses for consideration, as well as fields to be substituted during canned response rendering. For more information about canned response properties, refer to the [Canned Responses](https://parlant.io/docs/concepts/customization/canned-responses) section.
## Guideline Reevaluation Based on Tool Results
In some cases, tool results can influence which guidelines become relevant. Here's an example:
Consider a banking agent handling transfers. When a user requests a transfer, a guideline with the condition the user wants to make a transfer activates the `get_user_account_balance()` tool to check available funds. This tool returns the current balance, which can then trigger additional guideline matches based on its return value.
For instance, if the balance is below $500, we might have a low-balance guideline activate, instructing the agent to say something like: _"I see your current balance is low. Are you sure you want to proceed with this transfer? This transaction might put you at risk of overdraft fees."_
In Parlant, you can mark certain guidelines for reevaluation after a tool call. This means that once the tool is called, the guideline matcher will re-evaluate the session to see if any new guidelines should be activated based on the tool's results—*after* running the tool but *before* generating the response.
Here's how you can do that:
```python
# The agent will ensure to reevaluate this guideline after running this tool
await guideline.reevaluate_after(my_tool)
```
## Best Practices
#### A Note on Natural Language Programming
While LLMs excel at conversational tasks, they struggle with complex logical operations and multi-step planning. Recent research in LLM architectures shows that even advanced models have difficulty with consistent logical reasoning and sequential decision-making. The "planning problem" in LLMs—breaking down complex tasks into ordered steps and synthesizing conclusions—remains a significant unsolved problem when consistently at scale is required.
Given these limitations, Parlant takes a pragmatic approach: Separate logic in code from behavior modeling. Instead of embedding business logic in guidelines, Parlant encourages a clean separation between conversational behavior and underlying business operations.
Consider your tools as your place for deterministic, programmatic business logic, and guidelines as your conversational interface design. This separation creates cleaner, more maintainable, and more reliable systems.
> **Agentic API Design**
>
> To learn more about best-practices for designing your agent's tools, we recommend reading our blog post on [Agentic Backends](https://parlant.io/blog/what-no-one-tells-you-about-agentic-api-design).
#### Examples
**1. E-commerce Product Recommendations:**
DON'T
Complex business logic in guideline, over-relying on the LLM
> * **Guideline Action:**
> If user mentions sports, check their purchase history.
> If they bought running gear, recommend premium shoes.
> If they're new, suggest starter kit.
> * **Tool Associations:** `[get_product_catalog]`
DO
Logic goes in the coded recommendation engine, where it belongs
> * **Guideline Action:** Offer personalized recommendations
> * **Tool Associations:** `[get_personalized_recommendations]`
**2. Financial Advisory:**
DON'T
Financial analysis logic relies on unreliable LLM numeric comprehension
> * **Guideline Action:** Check account balance and recent transactions.
If spending exceeds 80% of usual pattern, suggest budget review.
If investment returns are down, recommend portfolio adjustment.
> * **Tool Associations:** `[get_account_data]`
DO
Financial analysis logic is handled reliably in code
> * **Guideline Action:** Get personalized financial insights
> * **Tool Associations:** `[get_financial_insights]`
## Tool Context
The `ToolContext` parameter is a special object that provides the tool with contextual information and utilities.
Let's look at some of the most useful attributes and methods available in the `ToolContext`:
1. `agent_id`: The unique identifier of the agent that is calling the tool.
2. `customer_id`: The unique identifier of the customer interacting with the agent.
3. `session_id`: The unique identifier of the current session.
4. `emit_message(message: str)`: A method to send a message back to the customer. This can be used to report progress during a long-running tool call.
5. `emit_status(status: p.SessionStatus)`: A method to update the [session status](https://parlant.io/docs/concepts/sessions#status-event).
#### Accessing Server Objects (Agent, Customer, etc.)
You can also access the server object from the `ToolContext`, giving you access to your agents, guidelines, and other server-level resources.
To access the server object, use `p.ToolContextAccessor` as follows:
```python
import parlant.sdk as p
@p.tool
async def my_tool(context: p.ToolContext) -> p.ToolResult:
server = p.ToolContextAccessor(context).server
# Access the current agent using the agent_id from the context
agent = await server.get_agent(id=context.agent_id)
# Access the current customer
customer = await server.get_customer(id=context.customer_id)
# ...
return p.ToolResult(...)
```
#### Secure Data Access
Suppose you need to build a tool that retrieves or displays data private to different customers.
A naive approach would be to ask the customer to identify themselves and use that as an access token into the right data. But this approach is highly insecure, as it relies on the LLM for identifying the user. The LLM can get it wrong or, worse yet, be manipulated by malicious users.
A better and more reliable way to do this is to [register your customers](https://parlant.io/docs/concepts/entities/customers#registering-customers) with Parlant and use the information available programmatically, which is contained in the `ToolContext` parameter of your tool.
Heres how that would look in practice:
```python
@p.tool
async def get_transactions(context: p.ToolContext) -> p.ToolResult:
transactions = await DB.get_transactions(context.customer_id)
return p.ToolResult(transactions)
```
## Tool Insights and Parameter Options
Because Parlant's architecture is radically modular, components like guideline matching, tool calling and message composition operate independently. While this non-monolithic approach offers many advantages in managing its complex semantic logic, it also requires it to communicate contextual awareness across these components.
**Tool Insights** is a bridging component between tool calling and message composition, ensuring the composition component is informed when a tool couldn't be called for some reason—for example, due to missing required parameters.
This allows the agent to respond more intelligently. For example, if it had no knowledge of when an appropriate tool couldn't be called, it might generate a misleading response. But with tool insights, the agent recognizes missing information and, if needed, can prompt the customer for the required tool arguments automatically.
#### Tool Parameter Options
To allow you to enhance the baseline behavior of Tool Insights, you can make use of **ToolParameterOptions**, a special parameter annotation which adds more control over how tool parameters are handled and communicated.
While Tool Insights helps the agent recognize when and why a tool call fails, **ToolParameterOptions** goes a step further by guiding the agent on when and how to explain specific missing parameters.
```python
from typing import Annotated
import parlant.sdk as p
@p.tool
async def transfer_money(
context: p.ToolContext,
amount: Annotated[float, p.ToolParameterOptions(
source="customer", # Only the customer can provide this value - the agent cannot infer it
)],
recipient: Annotated[str, p.ToolParameterOptions(
source="customer",
)]
) -> ToolResult:
# ...
```
The **ToolParameterOptions** consists of several optional arguments, each refining the agents understanding and application of the parameter:
- `hidden` If set to `True`, this parameter will not be exposed to message composition. This means the agent won't notify the customer if its missing. It's commonly used for internal parameters like opaque product IDs or any other information that should remain behind the scenes.
- `precedence` When a tool has multiple required parameters, the tool insights communicated to the customer can be overwhelming (e.g., asking for 5 different items in a single message). Precedence lets you create groups (which share the same value) such that the customer would only learn about a few (the ones sharing a precedence value) at a time—in the order you choose.
- `source` Defines the source of the argument. Should the agent request the value directly from the customer ("customer"), or should it be inferred from the surrounding context ("context")?
If not specified, the default is "any", meaning the agent can retrieve it from anywhere.
- `description` This helps the agent interpret the parameter correctly when extracting its argument from the context. Fill this if the parameter name is ambigious or unclear.
- `significance` A customer-facing description of why this parameter is required. This helps customers understand and relate to what information they need to provide and why.
- `examples` A list of sample values illustrating how the argument should be extracted. This is useful for enforcing formats (e.g., a date format like "YYYY-MM-DD").
- `adapter` A function that converts the inferred value into the correct type before passing it to the tool. If provided, the agent will run the extracted argument through this function to ensure it matches the expected format. Use when the parameter type is a custom type in your codebase.
- `choice_provider` A function that provides valid choices for the parameter's argument. Use this to constrain the agent to dynamically choose a value from a specific set returned by this function.
## Parameter Value Constraints
In cases where you need a tool's argument to fall into a specific set of choices, Parlant can help you ensure that the tool-call is parameterized according to those choices. There are three ways to go about it:
1. Use enums when you are able to provide hard-coded choices
1. Use `choice_provider` when the choices are dynamic (e.g., customer-specific)
1. Use a Pydantic model when the parameter follows a more complex structure
#### Enum Parameters
Specify a fixed set of choices that are known ahead of time, using an `Enum` class.
```python
import enum
class ProductCategory(enum.Enum):
LAPTOPS = "laptops"
PERIPHERALS = "peripherals"
MONITORS = "monitors"
@p.tool
async def get_products(
context: p.ToolContext,
category: ProductCategory,
) -> p.ToolResult:
# your code here
return p.ToolResult(returned_data)
```
#### Choice Provider
Dynamically offer a set of choices based on the current execution context.
```python
async def get_last_order_ids(context: p.ToolContext) -> list[str]:
return await load_last_order_ids_from_db(customer_id=context.customer_id)
@p.tool
async def load_order(
context: p.ToolContext,
order_id: Annotated[Optional[str], p.ToolParameterOptions(
choice_provider=get_last_order_ids,
)],
) -> p.ToolResult:
# your code here
return p.ToolResult({...})
```
#### Pydantic Model
Use a Pydantic model to define a complex structure for the parameter, which can include validation and constraints.
```python
from pydantic import BaseModel
class ProductSearchQuery(BaseModel):
category: str
price_range: tuple[float, float]
@p.tool
async def search_products(
context: p.ToolContext,
query: ProductSearchQuery,
) -> p.ToolResult:
# your code here
+133
View File
@@ -0,0 +1,133 @@
# Variables
Every customer is unique, and your agent may choose to treat them as such where appropriate.
Variables enrich the context that an agent sees about the customer it's talking to. They're meant to give your agent awareness to information that helps it personalize its service, much like how a thoughtful customer service representative knows and remembers important details about each client, to make them feel heard and understood.
When a customer interacts with an agent, their variables are automatically loaded into its context, allowing the agent to tailor its responses based on their specific situation.
### Real World Applications
Let's walk through how variables transform customer interactions. Imagine you're running a SaaS platform's support agent. You might track variables like subscription plan, last login date, which features each customer uses, and their company size.
Consider two different customers reaching out about data exports. Sarah, a startup founder on the free plan, asks "Can I export my data to Excel?" Your agent, aware of her free plan status, can respond thoughtfully: "While Excel export is a premium feature, I can show you how to use our basic CSV export. Would you also like to learn about the advanced reporting capabilities in our premium plan?"
Now imagine Tom from an enterprise account reaches out with the same question. The agent sees his enterprise status but also notices his team hasn't explored many advanced features. It might respond: "I'll help you with Excel exports! I notice your team hasn't tried our automated reporting suite yet - this is included in your enterprise plan and could save you hours each week. Would you like me to show you both features?"
### Working with Variables
A single variable identifies a particular piece of information. For example, you might create a variable called `"subscription_plan"`.
Each customer can then have a unique value assigned to them under that variable. For example, Tom might have `"enterprise"` as the variable's value.
There are two ways to set variable values.
1. Set their values manually
2. Attach them to a tool that automatically retrieves a value based on dynamic data
#### Creating Manually Set Variables
```python
variable = await agent.create_variable(
name=NAME,
description=DESCRIPTION,
)
```
#### Creating Tool-Enabled Variables
A variable that's associated with a tool will automatically update its value based on the tool's output. This is useful for dynamic data that changes frequently and of which the agent always needs to be aware.
Suppose you have the following tool:
```python
@p.tool
async def get_variable_value(context: p.ToolContext) -> p.ToolResult:
...
```
You can then create an auto-updating variable based on this tool as follows:
```python
variable = await agent.create_variable(
name=NAME,
description=DESCRIPTION,
tool=get_variable_value,
)
```
By default, the associated tool would update the variable's value before every agent response. If this frequent reload of data is unnecessary, you can control the refresh interval of the value (controlling how often the associated tool is called to generate a fresh value) by specifying its _freshness rules_.
```python
variable = await agent.create_variable(
name=NAME,
description=DESCRIPTION,
tool=get_variable_value,
freshness_rules=CRON_EXPRESSION,
)
```
Freshness rules follow the [cron expression](https://en.wikipedia.org/wiki/Cron) syntax. If you're new to cron, you can use tools like [crontab generator](https://crontab.cronhub.io/) to help you define the period syntax more easily.
> **Manual Values**
>
> Even tool-enabled variables can have their values set manually, if needed. This is useful for when you want to override the tool's output for specific customers or customer groups.
#### Setting a Variable Value for a Customer
```python
await variable.set_value_for_customer(
customer=CUSTOMER,
value=VALUE,
)
```
#### Setting a Variable Value for a Customer Group
You can also set the value of a variable for a [customer group](https://parlant.io/docs/concepts/entities/customers#customer-groups) by specifying the group's tag.
```python
await variable.set_value_for_tag(
tag=TAG_ID,
value=VALUE,
)
```
## Working Example
Here's how we'd implement a subscription plan variable.
```python
@p.tool
async def get_subscription_plan(context: p.ToolContext) -> p.ToolResult:
# Fetch the customer's subscription plan from your database
return p.ToolResult(await get_plan_from_database(context.customer_id))
```
```python
variable = await agent.create_variable(
name="subscription_plan",
description="The customer's subscription plan",
tool=get_subscription_plan,
)
await variable.set_value_for_customer(
customer=p.Customer.guest(),
value="Free Plan", # Default value for non-registered customers
)
```
### Combining Context Variables with Guidelines
Let's explore how guidelines and context variables work together to create truly intelligent interactions. Imagine you're running an AI support agent for a digital bank where customers have different account tiers and transaction patterns.
Here's a focused guideline:
```python
await agent.create_guideline(
condition="the customer's account_tier is 'basic' "
"AND they ask about instant international transfers",
action="highlight our same-day domestic transfers that are free on their plan, "
"then mention how the premium tier enables instant global payments with lower fees",
)
```
When Mark, who is on the basic tier, asks about sending money to his daughter studying abroad, instead of a flat "that's premium only" response, he hears: "I can help you send that money today using our standard international transfer. By the way, our premium accounts get this done instantly with lower fees—would you like to know more?"
+61
View File
@@ -0,0 +1,61 @@
# Agents
In Parlant, an agent is a customized AI personality that interacts with customers as a single, competent entity. It is essentially, "the one you talk to", as opposed to some frameworks where an agent is a specialized task function.
Agents form the basic umbrella of conversational customization—all behavioral configurations affect agent behavior.
```python
import parlant.sdk as p
async with p.Server() as server:
hexon = await server.agents.create(
name="Hexon",
description="Technical support specialist"
)
# Continue to model the agent's behavior using guidelines, journeys, etc....
```
> **Note:**
>
> Note that a single Parlant server may host multiple agents, each with distinct roles and personalities.
Each agent can be uniquely configured with its own style, demeanor, and interaction patterns tailored to its target users. More importantly, different business units can own and maintain their specific agents. For example:
* IT Department manages **Hexon**
* Customer Success team oversees **Sprocket**
* Sales/Marketing controls **Piston**
This agent-based design creates natural boundaries for separation of concerns within Parlant
### Crafting an Agent's Identity
Imagine you're creating a new employee who will become the voice of your service. Just as you'd carefully consider the personality and approach of a human hire, crafting an agent's identity ultimately requires thoughtful consideration of its core characteristics—and, like any good hire, you can grow and adapt it based on real-world feedback.
As an example, let's follow the possible evolution of **Hexon**, our technical support specialist. In its first iteration, we might simply define it as "a technical support agent who helps users solve technical problems professionally and efficiently." After observing some interactions, we might notice that it comes across as too mechanical, failing to build trust with users.
So we refine its identity:
> "A technical support specialist who combines deep technical knowledge with patient explanation. You take pride in making complex concepts accessible without oversimplifying them. While you're always professional, you communicate with a warm, approachable tone. You believe that every technical issue is an opportunity to help users better understand their tools. When users are frustrated, you remain calm and empathetic, acknowledging their challenges while focusing on solutions."
As we observe more interactions, we might further refine this general identity. Perhaps we notice users respond better when Hexon shows more personality, or maybe we find certain technical discussions need more gravitas. The identity can evolve with these insights.
The key is to start with an identity that gives the agent its basic orientation, but remain open to refinement based on real interactions. Watch how users respond to the agent's mannerisms. Gather feedback from stakeholders. Adjust the identity accordingly.
### A Single Agent or Multiple Agents?
There's a frequent debate on whether to model user-facing agents as a single agent or multi-agent system. Parlant's position is a mix of both.
Generally speaking, managing complexity is easier when our solutions model the real world, because it makes us naturally have much more data with which to reason about design decisions, rather than trying to come up with something contrived. So instead of asking a very fundamental, "How should users interact with this agent?" we can instead ask something much more fruitful, like, "What would user expect based on their real-life experience?"
In practice, when we interact with human service representatives, there are certain expectations we've come to have from such experiences:
- If we're talking to an agent, they have the full context of our conversation. They're coherent. They don't suddenly just forget or unexpectedly change their interpretation of the situation.
- The agent we're talking to may not always be able to help us with everything. We may need to be transferred to another agent who specializes in some topic.
- We expect to be notified of such transfers. If they happen suddently or without our awareness, we take that as a careless customer experience.
You can see how insights from familiar, real-world usage patterns help us arrive at informed design decisions. By modeling agent interactions on real-world patterns, we not only better understand what outcomes to strive for, but it turns out that managing our agents' configuration becomes easier to reason about, too.
This is why Parlant's formal recommendation is to model AI agents after how human agents work. In other words, if you can see it being a single personality in a real-life use case, that means it should be represented as a single AI agent in Parlant. Incidentally, Parlant's filtration of relevant elements of the agent's conversation model allow you to manage quite a lot of complexity in a single agent, so you don't need to adopt a multi-agent approach if that was your concern.
> **Tip: The Failures of Multi-Agent Systems**
>
> There's an interesting paper on the [failures of multi-agent systems](https://arxiv.org/abs/2503.13657#:~:text=We%20present%20MAST%20%28Multi-Agent%20System%20Failure%20Taxonomy%29%2C%20the,over%20200%20tasks%2C%20involving%20six%20expert%20human%20annotators), despite their promise of modularity and specialization. It highlights how multi-agent systems often struggle with coordination, communication, and consistency, leading to unexpected behaviors and failures. This aligns with Parlant's approach of using a single agent to maintain coherence and context in conversations.
+135
View File
@@ -0,0 +1,135 @@
# Customers
In Parlant, a **customer** is a code word for anyone who interacts with an agent—regardless of the real nature of the relationship between them. In other words, a customer can be a real person, a bot, or even a human agent.
While agents can operate anonymously (without knowing who they're talking to), Parlant allows you to track registered customers and provide deeply personalized experiences based on their identity and preferences.
By letting your agents understand who they're talking to, you can tailor interactions for different user segments: high-profile customers might receive premium offers, new users can get focused onboarding guidance, and so forth...
Parlant makes customer registration simple, requiring only minimal identification—a name is enough to get started.
```python
import parlant.sdk as p
async with p.Server() as server:
# Register a new customer
customer = await server.create_customer(name="Alice")
```
## Authentication
Parlant aims to live as a backend service, leaving authentication and authorization to the application layer. This means that while you can register customers, you should handle their authentication (e.g., via OAuth, JWT, etc.) in your application code, in whatever way suits your needs.
Once you have identified your customer, then you can pass their ID to the agent, allowing it to personalize interactions based on the registered customer.
## Storage
You can choose where you store customers.
By default, Parlant does not persist customers, meaning that they are stored in memory and will be lost when the server restarts. This is useful for testing and development purposes.
If you want to persist customers, you can configure Parlant to use a database of your choice. For local persistence, we recommend using the integrated JSON file storage, as there's zero setup required. For production use, you can use MongoDB, which comes built-in, or another database.
### Persisting to Local Storage
This will save customers under `$PARLANT_HOME/customers.json`.
```python
import asyncio
import parlant.sdk as p
async def main():
async with p.Server(customer_store="local") as server:
# ...
asyncio.run(main())
```
### Persisting to MongoDB
Just specify the connection string to your MongoDB database when starting the server:
```python
import asyncio
import parlant.sdk as p
async def main():
async with p.Server(customer_store="mongodb://path.to.your.host:27017") as server:
# ...
asyncio.run(main())
```
## Customer Groups
You can also divide your customers into different groups and control group-specific personalization by using **tags**.
For example, you can create a tag for VIP customers:
```python
# Create a new tag to represent VIP customers
vip_tag = await server.create_tag(name="VIP")
# Register a new customer
customer = await server.create_customer(name="Alice", tags=[vip_tag.id])
```
> **Tip: Learn More**
> To learn more about advanced personalization possibilities for specific customers and groups, check out the [variables](https://parlant.io/docs/concepts/customization/variables) section.
## Adding Metadata
You can also attach custom metadata to customers, which can be used to store additional information about them. This metadata can be used to further personalize interactions or to provide context for tool calls.
```python
customer = await server.create_customer(name="Alice", metadata={
"external_id": "12345",
"location": "USA",
})
```
```python
@p.tool
async def get_customer_location(context: p.ToolContext) -> p.ToolResult:
server = p.ToolContextAccessor(context).server
if customer := await server.find_customer(id=context.customer_id):
return p.ToolResult(customer.metadata.get("location", "Unknown location"))
return p.ToolResult("Customer not found")
```
## Registering Customers
While you can register customers using the SDK itself, it's often more practical to handle customer registration through your application layer. This allows you to integrate customer management with your existing user authentication and authorization systems.
You can do this by using Parlant's REST API or native Client SDKs to create and manage customers.
```python
from parlant.client import ParlantClient
# Change localhost to your server's address
client = ParlantClient("http://localhost:8800")
client.customers.create(
name="Alice",
metadata={
"external_id": "12345",
"location": "USA",
"hobby": "reading",
},
tags=[TAG_ID] # Optional: specify tag IDs to assign to the customer
)
```
## Updating Customer Data
You can update customer data at any time, including their name, metadata, and tags. This is useful for keeping customer information up-to-date as your application evolves.
```python
client.customers.update(
customer_id=CUSTOMER_ID,
name="Alice Smith",
metadata={
"set": {
"location": "Canada",
},
"remove": ["hobby"],
},
tags=[NEW_TAG_ID] # Optional: specify new tag IDs to assign to the customer
)
```
+363
View File
@@ -0,0 +1,363 @@
# Sessions
A session represents a continuous interaction between an [agent](https://parlant.io/docs/concepts/entities/agents) and a [customer](https://parlant.io/docs/concepts/entities/customers).
Sessions are the stage for your conversational model, allowing agents to engage with customers in a structured and persistent manner. They encapsulate all the interactions that occur between an agent and a customer, including messages, status updates, frontend events, and tool call results.
```mermaid
%%{init: { "theme": "neutral" }}%%
mindmap
root((Session))
Message History
Status Indicators
Frontend Events
Tool Results
```
> **Agent Memory?**
>
> What some frameworks call "memory" is already built-in into sessions in Parlant. An agent is constantly aware of everything that has happened in the session, using this information to apply the right instructions and generate appropriate responses.
## A Modern Interaction Model
Parlant views interaction sessions in a different manner than most Conversational AI frameworks.
In the past few decades, virtually all forms of conversational AI have assumed that an interaction occurs on a turn-by-turn basis, where a customer sends a message, and the agent responds to it.
Yet this is not how real conversations work. People often send each other multiple subsequent messages to communicate their thoughts. In addition, an agent may say something, put the customer on hold for a moment, and then return to the conversation with a follow-up message.
**Rigid Interaction Model**
```mermaid
sequenceDiagram
participant Customer
participant Agent
Customer->>Agent: Message 1
Agent->>Customer: Reply 1
Customer->>Agent: Message 2
Agent->>Customer: Reply 2
```
**Modern Interaction Model**
```mermaid
%%{init: { "theme": "forest" }}%%
sequenceDiagram
participant Customer
participant Agent
Customer->>Agent: Message 1
Customer->>Agent: Message 2
Agent-->>Customer: (Processing...)
Agent->>Customer: Reply to both messages
Agent->>Customer: Follow-up clarification
```
Since this is how real conversations work, Parlant provides built-in support for it from the ground up.
> **Multi-Participant Sessions**
>
> A requested feature on Parlant's development roadmap, this will allow you to have multiple agents interact with the customer, or with each other. Another use case for this is transferring the customer to another AI agent.
## Configuring Session Storage
You can choose where you store sessions.
By default, Parlant does not persist sessions, meaning that they are stored in memory and will be lost when the server restarts. This is useful for testing and development purposes.
If you want to persist sessions, you can configure Parlant to use a database of your choice. For local persistence, we recommend using the integrated JSON file storage, as there's zero setup required. For production use, you can use MongoDB, which comes built-in, or another database.
### Persisting to Local Storage
This will save sessions under `$PARLANT_HOME/sessions.json`.
```python
import asyncio
import parlant.sdk as p
async def main():
async with p.Server(session_store="local") as server:
# ...
asyncio.run(main())
```
### Persisting to MongoDB
Just specify the connection string to your MongoDB database when starting the server:
```python
import asyncio
import parlant.sdk as p
async def main():
async with p.Server(session_store="mongodb://path.to.your.host:27017") as server:
# ...
asyncio.run(main())
```
## Event Driven Communication
Think of a session in Parlant as a timeline of everything that's happened in a conversation.
Each moment in this timeline—whether it's someone speaking, a status update, or a tool call result—is captured as an event. These events line up one after another, each with its own position number (called its _offset_), starting from 0.
When a conversation unfolds, it creates a sequence of events. A customer might start the session by saying _"Hello"_—that's event 0. The system then notes that the agent has acknowledged the message and is preparing a response by outputting a status event—that's event 1. The agent's _"Hi there!"_ becomes event 2, and so on. Each event, whether it's a message being exchanged, the agent typing, or even an error occurring, takes its place in this ordered sequence.
```mermaid
%%{init: { "theme": "forest" }}%%
graph LR
direction LR
Ax["Event 0"] --> Bx["Event 1"] --> Cx["Event 2"] --> Dx["Event 3"] --> Ex["Event 4"]
A["Customer(Cash remaining?)"] --> B["Status(Thinking)"] --> C["Tool(get_balance)"] --> D["Status(Typing)"] --> E["Agent(Your balance is $100)"]
Ax --- A
Bx --- B
Cx --- C
Dx --- D
Ex --- E
```
Every event in this sequence carries important information: what type of event it is (like a message or a status update), what actually happened (the data), and when it occurred. This creates a complete record of the conversation that helps us understand exactly how things unfolded, making it easy to track and review the conversation's state when needed.
Each event is also associated with a **trace ID**. This ID primarily helps to trace between AI-generated messages and the engine triggers that produced them, including any generated tool events that may have informed them. This lets us easily fetch and understand the data that went into each generated message. For example, by having your frontend client inspect a message's traced tool events, you can show relevant information in "footnotes" under the message.
## Interacting with an Agent
Once you have a Parlant server up and running, you can interact with its hosted agents through the [REST API](https://parlant.io/docs/api/create-session).
You have three options:
1. Use the official React widget to quickly and easily integrate with the server
2. Use the official client SDKs for Python or TypeScript to build a custom frontend application
3. Use the [REST API](https://parlant.io/docs/api/create-session) directly by making HTTP requests to the server in your language of choice
### Using the Official React Widget
If your frontend project is built with React, the fastest and easiest way to start is to use the official Parlant React widget to integrate with the server.
Here's a basic code example to get started:
```jsx
import React from 'react';
import ParlantChatbox from 'parlant-chat-react';
function App() {
return (
<div>
<h1>My Application</h1>
<ParlantChatbox
server="PARLANT_SERVER_URL"
agentId="AGENT_ID"
/>
</div>
);
}
export default App;
```
For more documentation and customization, see the **GitHub repo:** https://github.com/emcie-co/parlant-chat-react.
```bash
npm install parlant-chat-react
```
### Building a Custom Frontend
If you're coding in Python or TypeScript, you can use the official, native client SDKs for a fully-typed experience.
**Python**
```bash
pip install parlant-client
```
**TypeScript**
```bash
npm install parlant-client
```
We'll now cover some basic use cases. The examples will be in Python, but the other SDKs have nearly identical APIs, so you can easily adapt them to your preferred language.
#### Initializing the Client
```python
from parlant.client import AsyncParlantClient
# Change localhost to your server's address
client = AsyncParlantClient(base_url="http://localhost:8800")
```
> **Async Client?**
>
> The examples given here use the asynchronous client, which is the recommended way to interact with Parlant. This allows you to handle events in real-time without blocking your application. It's usually much better for production use.
>
> However, if you prefer a synchronous client—for example, if you're just testing—you can use `ParlantClient` instead of `AsyncParlantClient`. The API remains the same, but you don't have to run within an async event loop.
#### Creating a Session
```python
await client.sessions.create(
agent_id=AGENT_ID, # The ID of the agent to interact with
# Optional parameters
customer_id=CUSTOMER_ID, # Optional: defaults to the guest customer's ID
title=SESSION_TITLE, # Optional: session can be untitled
)
```
#### Sending Customer Messages to an Agent
You can send messages to an agent by creating a new message event in the session. This is how you initiate a conversation or continue an existing one.
```python
event = await client.sessions.create_event(
session_id=SESSION_ID,
kind="message", # The event is of type 'message'
source="customer", # The message is from the customer
message="Hello, I need help with my order.",
)
```
#### Receiving Messages from an Agent
As stated before, unlike LLM APIs where you send a prompt and wait for a direct response, Parlant agents operate in their own timeline according to triggers, more like real conversation partners.
Much like a human service representative, they process information and decide when and how to respond based on their understanding of the context. This allows you to build much more flexible, ambient agentic experiences that can engage with customers proactively.
However, it also means we need to approach communication with them differently. Here's how you can do that:
```python
new_events = await client.sessions.list_events(
session_id=SESSION_ID,
min_offset=EVENT_OFFSET, # The offset of the last event you received (or created yourself)
wait_for_data=60, # Wait for up to 60 seconds for new events, before timing out
)
```
Normally, you'd have this polling in a loop. This way, you can keep checking for new events in the session, allowing you to receive messages from the agent asynchronously, whenever they arrive, due to whatever reason.
```mermaid
graph LR
A["Fetch new events"] -->|Timeout| A
A --> |New events| B["Display new events"]
B --> A
```
### Displaying Messages
Consult the message event's structure below to see how to display messages in your frontend application.
Here's a simple example:
```python
agent_message = next((m for m in new_events if m.kind == "message" and m.source == "ai_agent"), None)
if agent_message:
print(f"Agent: {agent_message.data['message']}")
```
### Events
If you decide to build a custom frontend, here's a quick overview of Parlant's event structure.
#### Event Types
Parlant defines several event types that you can work with:
1. `"message"`: Represents a message sent by a participant in the conversation.
2. `"status"`: Represents a status update from the AI agent, such as "thinking...", or "typing...".
3. `"tool"`: Represents the result of a tool call made by the AI agent.
4. `"custom"`: Represents a custom event defined by your application. This is useful for feeding custom state updates into your agent, e.g., making it aware of the customer's navigational state within your frontend application.
#### Event Offset
As said above, events are ordered by their offset, which is a number that indicates the order in which they occurred within the session. The first event in a session has an offset of 0, the second has an offset of 1, and so on.
This is useful because, when you list events, you can specify a minimum offset to only receive events that occurred after a certain point in time. This allows you to poll new events without having to re-fetch all previous ones.
#### Event Trace ID
Each event has a trace ID, which is a unique identifier that helps you track related events and their logs.
As one example, when an AI agent generates a message, it may also generate tool events that provide additional context or data used in that message. The trace ID allows you to link these events together, making it easier to understand the flow of information as well as your agent's processing in the session.
#### Event Sources
Events in Parlant can originate from different sources. Here's a quick overview of the possible sources:
1. `"customer"`: The event's data was created by the customer. Currently, this is always a message.
2. `"customer_ui"`: The event was created by the customer's user interface, to feed relevant state into the agent.
3. `"ai_agent"`: The event was generated by an AI agent, such as a message or a status update.
4. `"human_agent"`: The event was manually created by a human, typically in a human-handoff scenario.
5. `"human_agent_on_behalf_of_ai_agent"`: As above, the event was created by a human agent, but it appears to the customer as if it came from an AI agent. This can be useful for maintaining a consistent experience where you don't necessarily want to reveal the fact that a human agent got involved.
6. `"system"`: The event was generated by the system, such as a tool-call result.
#### Message Event
A message event, as its name suggests, represents a message written by someone.
```json
{
id: EVENT_ID,
kind: "message",
source: EVENT_SOURCE,
offset: N,
trace_id: TRACE_ID,
data: {
message: MESSAGE,
participant={
id: PARTICIPANT_ID,
display_name: PARTICIPANT_DISPLAY_NAME
},
draft: OPTIONAL_DRAFT, // Optional: if the message is a canned response
}
}
```
#### Status Event
A status event represents an update on the status of the AI agent, and currently always has the source `"ai_agent"`.
Status events are great for displaying conversational updates during a chat with a customer. For example, you can have your frontend indicate when the agent is thinking or typing. There are 6 kinds of status events that you can make use of:
1. `"acknowledged"`: The agent has acknowledged the customer's message and started working on a reply
1. `"cancelled"`: The agent has cancelled its reply in the middle, normally because new data was added to the session
1. `"processing"`: The agent is evaluating the session in preparation for generating an appropriate reply
1. `"typing"`: The agent has finished evaluating the session and is currently generating a message
1. `"ready"`: The agent is idle and ready to receive new events
1. `"error"`: The agent encountered an error while trying to generate a reply
```json
{
id: EVENT_ID,
kind: "status",
source: "ai_agent",
offset: N,
trace_id: TRACE_ID,
data: {
status: STATUS_KIND,
data: OPTIONAL_DATA
}
}
```
#### Tool Event
A tool event represents the result of a tool call made by the AI agent. It contains the result of the tool call, which can be used to inform the agent's next message.
The `result` object for each call comes directly from the [ToolResult](https://parlant.io/docs/concepts/customization/tools#tool-result) object returned by tool calls.
```json
{
id: EVENT_ID,
kind: "tool",
source: "system",
offset: N,
trace_id: TRACE_ID,
data: {
tool_calls: [
{
tool_id: TOOL_ID,
arguments: {
NAME: VALUE,
...
},
result: {
data: TOOL_RESULT_DATA, // The result of the tool call
metadata: TOOL_RESULT_METADATA, // Optional metadata about the tool result
... // Other available fields
}
},
...
]
}
}
```
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

+69
View File
@@ -0,0 +1,69 @@
# Interaction Flow
## Motivation
The first thing that's important to understand about the design of the Human/AI interface in Parlant is that it's meant to facilitate conversations that aren't only natural in content, but also in their flow.
Most traditional chatbot systems (and most LLM interfaces) rely on a request-reply mechanism based on a single last message.
```mermaid
stateDiagram
direction LR
HumanMessage --> AIProcessing: AI processes single message
AIProcessing --> AIMessage: AI sends response
AIMessage --> HumanMessage: Human replies
```
However, these days we know that a natural text interface must allow for a few things that are unsupported by that traditional model:
1. A human often expresses themselves in more than a single message event, before they're fully ready for a reply from the other party.
1. Information regarding their intent needs to be captured from not only their last N messages, but from the conversation as a whole.
```mermaid
stateDiagram
direction LR
MultipleHumanMessages --> AIProcessing: AI processes multiple messages in the session
AIProcessing --> AIMessage: AI sends response
AIMessage --> MultipleHumanMessages: Human replies in one or more messages
```
Moreover, the agent may need to respond not just when triggered by a human message; for example, when it needs to follow-up with the user to ensure their message was received, to try another engagement tactic, or to buy time before replying with further information, e.g., "Let me check that and get back to you in a minute."
## Solution
Parlant's API and engine is meant to work in an asynchronous fashion with respect to the interaction session. In simple terms, this means that both the human customer and the AI agent are free to add events (messages) to the session at any point in time, and in any number—just like in a real IM App conversation between two people.
### Sending Messages
```mermaid
graph LR
Client(Interaction Client) -->|Event Creation Request| API[Parlant REST API]
API -.->|Created Event| Client
API --> CheckEventType{Check Event Type}
CheckEventType -->|Is Customer Message| AddToSession[Add message to session and trigger the agent]
CheckEventType -->|Is AI Agent Message| TriggerAgent[Directly trigger the agent to react to the session]
CheckEventType -->|Is Human Agent Message| AddHumanAgentMessage[Add a pre-written message on behalf of the AI agent]
```
The diagram above shows the API flows for initiating changes to a session.
1. **Customer Message:** This request adds a new message to a session on behalf of the customer, and triggers the AI agent to respond asynchronously. This means that the *Created Event* does not in fact contain the agent's reply—that will come in time—but rather the ID (and other details) of the created and persisted customer event.
1. **AI Agent Message:** This request directly activates the full reaction engine. The agent will match and activate the relevant guidelines and tools, and produce a reply. The *Created Event* here, however, is not the agent's message, since that may take some time. Instead, it returns a *status event* containing the same *Trace ID* as the eventual agent's message event. It's important to note here that, in most frontend clients, this created event is usually ignored, and is provided mainly for diagnostic purposes.
1. **Human Agent Message:** Sometimes it makes sense for a human (perhaps a developer) to manually add messages on behalf of the AI agent. This request allows you to do that. The *Created Event* here is the created and persisted manually-written agent message.
### Receiving Messages
Since messages are sent asyncrhonously, and potentially simultaneously, receiving them must be done in asynchronous fashion as well. In essence, we are to always wait for new messages, which may arrive at any time, from any party.
Parlant implements this functionality with a long-polling, timeout-restricted API endpoint for listing new events. This is what it does behind the scenes:
```mermaid
graph LR
Client[Interaction Client] -->|Await & Fetch New Events| API[Parlant REST API]
API -->|"list_events(min_offset,...)"| SessionStore
API -->|"wait_for_events(min_offset,timeout)"| SessionListener
SessionListener -.->|true/false| API
```
When it receives a request for new messages, that request generally has 2 important components: 1) The session ID; and 2) The minimum event offset to return. Normally, when making a request to this endpoint, the frontend client is expected to pass the session ID at hand, and *1 + the offset of its last-known event*. This will make this endpoint return only when *new* messages arrive. It's normal to run this long-polling request in a loop, timing-out every 60 seconds or so and renewing the request while the session is open on the UI. It's this loop that continuously keeps your UI updated with the latest messages, regardless of when they arrive or what caused them to arrive.
In summary, Parlant implements a flexible conversational API that supports natural, modern Human/AI interactions.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

+345
View File
@@ -0,0 +1,345 @@
# Agentic Design Methodology
Building AI agents takes a fundamental paradigm shift from traditional software development. This article explores the unique challenges, methodologies, and design principles needed to create effective customer-facing agents.
While Parlant provides the tools for reliable agent behavior, success depends on mastering the art of semantic design—learning how to articulate instructions that work consistently at scale while maintaining natural user interactions.
## Understanding Probabilistic Behavior
AI agents operate differently from traditional software systems. In conventional development, deterministic functions produce consistent outputs for the same inputs. AI agents, however, are built on statistical models where the same input can produce varied responses based on the model's learned patterns and probability distributions.
Naturally, when we're building on top of an inherently uncertain foundation, this requires a different approach to design and implementation. This is the first important thing to pause and come to terms with about agentic design.
### Instruction Interpretation Challenges
While traditional software executes explicit commands with predictable outcomes, LLMs interpret instructions contextually, filling in details and assumptions based on their varied training data. They _have_ to work like this.
Consider this guideline example:
```python
agent.create_guideline(
condition="Customer is unhappy",
action="Make them feel better"
)
```
Both the condition and instruction are too vague and could result in undesirable behaviors:
- Offering unauthorized discounts
- Making promises the company cannot fulfill
- Using inappropriate communication styles
> **Warning: Interpretation Variability**
>
> LLMs trained to be helpful will attempt to fulfill requests even when they lack sufficient context or specificity. This can lead to responses that seem appropriate to the model but violate business rules or expectations.
## The Challenge of Complete Control
It's important to understand that, while Parlant adds many compliance mechanisms on top of LLMs, the LLMs themselves cannot be fully constrained from discussing certain topics, for two fundamental reasons:
**1. Pattern Mimicking, Not Reasoning**: LLMs don't actually "reason" in the logical sense. Everything they produce is essentially mimicking patterns of expression observed during training. Think of an LLM as a powerful but wild horse—it has immense capability, but it takes skill and nuance to "ride" it effectively.
**2. Contextual Ambiguity**: Even carefully crafted conditions and actions can become ambiguous across different and variegated interaction contexts. What seems clear in one scenario may be interpreted differently in a different context.
### Strategies for Compliance
For agents that must meet compliance standards and expectations, you need a layered approach:
**The Minimum: Guidance-Based Boundaries**
With guidelines, you can:
1. Set clear boundaries for acceptable behavior
2. Provide deliberate nudges and instructions for handling specific scenarios in intended ways
```python
await agent.create_guideline(
condition="Customer asks about topics outside your designated scope",
action="Politely decline to discuss the topic and redirect to what you can help with"
)
```
```python
await agent.create_guideline(
condition="The patient wants an analysis of their lab results",
action="Never provide any interpretation of the results. Instead, tell them to "
"call our office and ask to speak with their doctor for a detailed analysis",
)
```
**The Robust Solution: Canned Responses**
For truly critical interactions where unauthorized communication could cause problems, implement [canned responses](https://parlant.io/docs/concepts/customization/canned-responses) and set your agent's composition mode to `STRICT`:
```python
await agent.create_canned_response(
template="I can help you with account questions, but I'll need to connect you "
"with a specialist for policy details. Would you like me to transfer you?"
)
```
Canned responses ensure that in high-risk scenarios, your agent uses pre-approved language and content that eliminates the possibility of unauthorized statements. Yes, this requires more work, but you can add these iteratively. The key insight is building an agent you can trust not to create liability—not even one time in a million interactions.
This "defense in depth" approach acknowledges that working with LLMs means learning to guide, steer and constrain, rather than control completely. It also means that, _as long as the behavior of the agent is within acceptable bounds,_ we must allow for some degree of flexibility and variability in responses.
## Tool Calling Complexities
When agents need to interact with external systems, they use tools (functions that perform specific actions). However, LLMs face unique challenges when calling tools that don't exist in traditional software development.
### The Parameter Guessing Problem
LLMs must determine tool parameters based on conversational context rather than explicit specifications. This creates several common failure patterns:
1. **Missing Information**: Agents may call tools without all required parameters being present in context, encouraging them to guess or hallucinate values.
1. **Type Confusion**: An agent might pass an email address where a user ID is expected, or provide a string where an integer is needed.
1. **Context Misinterpretation**: When multiple entities exist in conversation context, agents may use the wrong one for a parameter.
1. **False Positive Bias**: When multiple tools seem applicable, agents may call the first one that seems relevant, even if it's not the best fit.
Consider a user saying: "Schedule a meeting with Sarah for next week." The agent must determine:
- Which Sarah (if multiple exist)
- What day/time "next week" means
- What type of meeting
- How long the meeting should be
- What calendar system to use
Each ambiguity is a potential failure point. Parlant therefore provides you with specific controls to guide the contextual relevance of tools, as well as their precise parameterization expectations.
> **Tip: Tool Design Deep Dive**
>
> Tool calling presents unique challenges for agents, from parameter interpretation to multi-step orchestration failures. For comprehensive guidance on designing agent-friendly tools, particularly for customer-facing scenarios with Parlant, see:
>
> - [Tools documentation](https://parlant.io/docs/concepts/customization/tools) - Parlant's approach to guided tool usage
> - [Agentic API Design blog post](https://parlant.io/blog/what-no-one-tells-you-about-agentic-api-design) - Detailed strategies for building reliable agent-friendly APIs
## Iterative Development Process
Realistically, semantic behavior cannot be fully specified upfront like traditional software requirements. Instead, agent design follows an iterative process where behavior is best refined based on observed interactions and feedback.
### Phase 1: Basic Agent Implementation
When you're starting out, focus on implementing the core functionality and happy-paths of your agent, as far as you're able to define them. This means defining the basic guidelines and journeys that cover the most common scenarios.
Focus on getting core functionality to work before addressing edge cases. The good news is that Parlant's framework allows you to start simple and build complexity over time in a fairly straightforward manner.
### Phase 2: Monitoring and Analysis
Deploy the agent in a controlled environment and monitor its interactions. Unexpected behaviors provide insights into how the agent interprets instructions differently than intended. It'll also show you how users _actually_ interact with the agent, which is often somewhat different than most of us initially expect as we're designing them!
Track these interaction patterns:
- Situations where the agent deviates from expected responses
- Triggers that lead to undesired behaviors
- User confusion, frustration points, or peculiar interaction patterns
```mermaid
%%{init: { "theme": "neutral" }}%%
flowchart LR
A[Deploy Agent] --> B[Monitor Interactions]
B --> C{Unexpected Behavior?}
C -->|Yes| D[Adjust Behavior Model to Resolve Issues]
C -->|No| E[Expand Behavior Model with New Features]
D --> F[Test Staged Changes]
E --> F
F --> A
style C fill:#fff2cc,stroke:#d6b656
style D fill:#ffe6e6,stroke:#d79b9b
style E fill:#e6ffe6,stroke:#9bb99b
```
### Phase 3: Targeted Refinements
Leverage Parlant's structured approach to behavior modeling to address specific issues identified during monitoring. Add [guidelines](https://parlant.io/docs/concepts/customization/guidelines) that target observed problems:
```python
# Problem: Agent was repeating upsell offers after explicit rejection
await agent.create_guideline(
condition="Customer has explicitly declined a premium upgrade in this conversation",
action="Do not mention upgrades again in this session"
)
```
```python
# Problem: Agent gave vague responses when appointments were unavailable
await agent.create_guideline(
condition="Customer requests a specific appointment time that is not available",
action="Immediately provide the three closest available time slots as concrete alternatives",
tools=[get_available_slots],
)
```
### Guideline Specificity Requirements
Effective guidelines specify the temporal scope of their application and provide concrete, actionable instructions.
When designing guidelines, it's best to address these common ambiguity sources:
**Action Temporal Scope**: How long should the guideline's effect last?
- "...throughout the conversation" - applies throughout the current session
- "...immediately" - applies to the next response only
- "...until the customer has..." - applies until a specific condition changes
**Action Clarity**: What exactly should the agent do?
- Guide the response content: "Tell them that..."
- Specify objective criteria: "three closest alternatives" not "some alternatives"
**Condition Precision**: When exactly does this guideline apply?
- "Customer has explicitly declined" is clearer than "Customer is unhappy"
- "Customer asks about a specific policy and you don't have the exact answer" is more precise than "You're unsure"
## Managing Probabilistic Behavior
Agent design requires balancing flexibility with predictability. Agents need sufficient freedom to handle varied user inputs naturally while maintaining consistent adherence to business rules.
### Implementing Bounded Flexibility
Effective guidelines provide clear boundaries while allowing natural conversation flow:
```python
# Too rigid - feels scripted
await agent.create_guideline(
condition="Customer asks about pricing",
action="Say exactly: 'Our premium plan is $99/month'"
)
```
```python
# Too open - unpredictable behavior
await agent.create_guideline(
condition="Customer asks about pricing",
action="Help them understand our pricing"
)
```
```python
# Balanced approach - specific but flexible
await agent.create_guideline(
condition="Customer asks about pricing",
action="Explain our pricing tiers clearly, emphasize value, "
"and ask about their specific needs to recommend the best fit"
)
```
### Handling Edge Cases
Agents will encounter unexpected inputs and edge cases. Design guidelines to handle these situations gracefully:
```python
# Immediate escalation for policy questions
await agent.create_guideline(
condition="Customer asks about a specific policy and you don't have the exact answer",
action="Tell them you want to ensure they get accurate policy information, "
"and offer to connect them to human support who can provide the specifics"
)
```
```python
# Redirect competitor questions once per conversation
await agent.create_guideline(
condition="Customer asks about competitor products or pricing",
action="Acknowledge their question, explain that you focus on our own products, "
"and ask specifically what features or capabilities they're looking for "
"so you can recommend the best option from our lineup"
)
```
## Structured Interactions
For complex multi-step processes, guidelines alone may not provide sufficient structure. [Journeys](https://parlant.io/docs/concepts/customization/journeys) offer a better approach for these scenarios.
### When to Use Journeys
Consider implementing journeys when agents struggle with complex, multi-step interactions:
```python
# Instead of many guidelines trying to handle booking flow, use a structured journey...
booking_journey = await agent.create_journey(
title="Book Appointment",
conditions=["Customer wants to schedule an appointment"],
description="Guide customer through appointment booking process"
)
# Create a clear, flexible flow
t1 = await booking_journey.initial_state.transition_to(
chat_state="Ask what type of service they need"
)
t2 = await t1.target.transition_to(
tool_state=check_availability_for_servic_for_servicee,
)
t3 = await t2.target.transition_to(
chat_state="Offer available time slots"
)
# ... continue building the journey
```
Journeys provide conversational structure while maintaining flexibility, allowing agents to adapt to different interaction patterns within a defined framework.
## Development Philosophy for Customer-Facing Agents
Building customer-facing agents requires balancing several competing priorities that don't exist in traditional software development.
### User Experience vs. Business Control
Traditional user interfaces provide users with explicit options—buttons, forms, menus. Users can only do what the interface allows. Conversational agents invert this relationship: users can say anything, and the agent must decide how to respond within business constraints.
This creates a unique tension. Users expect natural, helpful interactions, but businesses need predictable, compliant behavior. The agent must feel conversational while operating within defined boundaries.
### Conversational Design Principles
**Context Preservation**: Unlike web forms that capture data step-by-step, conversations are non-linear. Users might provide information out of order, change their minds, stall, repeat themselves, or digress. Agents must maintain compliance while allowing natural conversation flow.
**Progressive Disclosure**: Rather than overwhelming users with all options upfront, agents can reveal capabilities contextually. This requires guidelines that respond to user needs as they emerge.
**Recovery Mechanisms**: When conversations go off-track, agents need explicit strategies to redirect without frustrating users. This often requires journey-scoped guidelines that handle common deviations.
### Protocol Adherence and Communication Standards
Customer-facing agents must follow established protocols and communicate in ways that align with business standards. The primary challenge is ensuring agents never provide misleading information while expressing things in the manner your organization approves of—which includes branding guidelines.
Parlant provides four main tools for maintaining communication standards:
1. **Guidelines:** Set behavioral boundaries and response patterns
1. **Journeys:** Structure complex interactions to ensure proper protocol adherence
1. **Canned Responses:** Guarantee exact wording for tailored communications
1. **Retrievers:** Grounds the agent's responses in accurate, up-to-date information
This layered approach ensures agents accurately follow protocol while maintaining natural conversation flow, never saying something critically misleading, and always expressing information in business-approved ways.
## The Art of Behavior Modeling
The primary challenge in agentic development isn't technical—it's designing effective interactions and then translating those designs into instructions that work reliably at scale, and that customers actually engage with.
Effective behavior modeling often combines:
**Domain Knowledge**: Understanding not just what customers need, but how they express those needs, what frustrates them, and what builds their confidence.
**Conversation Flow Design**: Knowing how to structure multi-turn interactions that feel natural while efficiently gathering necessary information.
**Instruction Design**: The skill of writing guidelines that are clear and precise enough for consistent LLM interpretation but flexible enough for natural conversation and adaptivity.
### Practical Design Strategies
**Start with User Stories, Not Features**: Instead of "the agent should handle returns," put yourself in your customer's shoes, like "As a customer who bought the wrong size, I want to exchange it..."
**Use Progressive Complexity**: Begin with the simplest possible behavior model that handles the common case. Add complexity only when specific edge cases are found. Parlant makes this iteration fairly straightforward.
**Separate Intent from Implementation**: Guidelines should focus on what clear outcome to achieve rather than specific words to use. This allows the agent to adapt its approach while maintaining consistent goals.
## The Twofold Challenge of Agentic Development
Agentic development involves two distinct but related problems:
1. **Articulating Instructions**: Designing a high-quality behavior model that captures your intended behavior
2. **Ensuring Compliance**: Guaranteeing that agents actually follow these instructions consistently at scale
Parlant solves the second challenge effectively. Once you've articulated your expectations clearly, Parlant's guideline matching, journey management, and enforcement mechanisms ensure reliable adherence to your specifications. The framework handles the complex task of dynamically selecting relevant guidelines, managing conversation context, and supervising agent outputs.
However, Parlant cannot solve the first challenge for you. The framework provides powerful tools for expressing conversational behavior, but it's on us as developers to learn how to use these tools effectively. This is where the real expertise lies—not just in understanding Parlant's SDK, but in developing the skills to design conversations and articulate instructions that work in practice.
**The Framework's Role**: Parlant ensures that well-designed guidelines are followed reliably across thousands of interactions. It handles the technical complexity of context management, guideline selection, and behavioral enforcement.
**The Developer's Role**: Learning to write guidelines that are neither too vague nor too rigid, designing journeys that accommodate real user behavior, and developing the judgment to know when to add structure versus when to allow flexibility.
This division of responsibility means that mastering agentic development requires both technical proficiency with Parlant's capabilities and behavior modeling expertise. The most successful implementations recognize that behavior modeling is a specialized skill involving continuous refinement through real-world testing.
## Implementation Guidelines: Summary
Effective agentic design requires understanding how to work with probabilistic behavior effectively:
1. **Begin with basic functionality** - Implement core capabilities before addressing edge cases
2. **Monitor systematically** - Track agent behavior to identify areas for improvement
3. **Refine iteratively** - Add guidelines based on observed rather than hypothetical issues
4. **Balance flexibility and control** - Provide clear boundaries while allowing natural interaction
5. **Structure complex flows** - Use journeys for multi-step processes
6. **Maintain transparency** - Communicate capabilities and limitations clearly
The primary challenge isn't technical mastery of Parlant's features, but developing the behavior modeling expertise to articulate instructions that work reliably at scale. Parlant handles the enforcement—your role is learning the art of clear agentic design.
+252
View File
@@ -0,0 +1,252 @@
# API Hardening
Parlant provides a robust authorization and rate limiting system to protect your API from unauthorized access and abuse. This guide explains how to implement custom authorization policies and rate limiters to secure your production deployment.
## Overview
The API hardening system consists of two main components:
1. **Authorization Policies** - Control who can access what resources and perform which actions
2. **Rate Limiters** - Prevent abuse by limiting the frequency of requests
Both components work together to provide comprehensive API protection, with support for different limits based on access tokens or user tiers.
## Authorization Policies
### Understanding the AuthorizationPolicy Abstract Class
All authorization policies inherit from the `AuthorizationPolicy` abstract base class, which defines three key methods:
```python
class AuthorizationPolicy:
@abstractmethod
async def check_permission(
self,
request: fastapi.Request,
permission: AuthorizationPermission
) -> bool:
"""Check if the request has permission to perform the action"""
...
@abstractmethod
async def check_rate_limit(
self,
request: fastapi.Request,
permission: AuthorizationPermission
) -> bool:
"""Check if the request is within rate limits"""
...
async def authorize(
self,
request: fastapi.Request,
permission: AuthorizationPermission
) -> None:
"""Combined authorization check (permission + rate limit)"""
# This method usually isn't overriden, as its default implementation
# calls the two abstract methods in sequence and raises an authorization
# error if anything is denied.
...
```
### Authorization Permissions
Parlant defines a comprehensive set of permissions as an enum covering all API operations:
- Agent operations (create, read, update, delete)
- Customer management
- Session handling
- And many more...
### Built-in Authorization Policies
#### DevelopmentAuthorizationPolicy
Allows all actions - suitable for development environments only:
```python
class DevelopmentAuthorizationPolicy(AuthorizationPolicy):
async def check_permission(
self,
request: fastapi.Request,
permission: AuthorizationPermission
) -> bool:
return True
async def check_rate_limit(
self,
request: fastapi.Request,
permission: AuthorizationPermission
) -> bool:
return True
```
#### ProductionAuthorizationPolicy
Implements stricter controls for production use with configurable rules.
## Implementing Custom Authorization Policies
When you implement your own authorization policy in real-world deployments, you typically want to extend the existing production policy rather than building from scratch. The recommended approach is to subclass `ProductionAuthorizationPolicy` and customize it for your specific needs.
Here's a reference implementation that demonstrates how to create a custom policy with JWT authentication:
```python
import parlant.sdk as p
import jwt
from fastapi import HTTPException
from limits import RateLimitItemPerMinute, RateLimitItemPerHour
from limits.storage import RedisStorage
from limits.strategies import SlidingWindowCounterRateLimiter
class CustomAuthorizationPolicy(p.ProductionAuthorizationPolicy):
def __init__(self, secret_key: str, algorithm: str = "HS256"):
super().__init__()
self.secret_key = secret_key
self.algorithm = algorithm
async def _extract_token(self, request: fastapi.Request) -> dict | None:
"""Extract and validate JWT token from request"""
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return None
token = auth_header.split(" ")[1]
try:
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
return payload
except jwt.JWTError:
# Raise 403 for invalid tokens, None for missing tokens is OK
raise HTTPException(
status_code=403,
detail="Invalid access token"
)
async def check_permission(
self,
request: fastapi.Request,
operation: p.Operation
) -> bool:
"""Enhanced permission checking with M2M token support"""
token_payload = await self._extract_token(request)
# If we have a valid M2M (machine-to-machine) token, allow additional operations
if token_payload and token_payload.get("type") == "m2m":
m2m_operations = {
# Allow M2M tokens to perform administrative operations
p.Operation.CREATE_AGENT,
p.Operation.READ_AGENT,
p.Operation.UPDATE_AGENT,
p.Operation.DELETE_AGENT,
p.Operation.CREATE_CUSTOMER,
p.Operation.READ_CUSTOMER,
p.Operation.UPDATE_CUSTOMER,
p.Operation.DELETE_CUSTOMER,
p.Operation.CREATE_CUSTOMER_SESSION,
p.Operation.LIST_SESSIONS,
p.Operation.UPDATE_SESSION,
p.Operation.DELETE_SESSION,
# Add other operations your M2M integration needs
}
if operation in m2m_operations:
return True
# For all other cases, delegate to the parent ProductionAuthorizationPolicy
return await super().check_permission(request, operation)
```
## Rate Limiting Customization Options
The `ProductionAuthorizationPolicy` provides several ways to customize rate limiting behavior:
### 1. Override the Default Rate Limiter (Recommended)
The most common approach is to override `self.default_limiter` with your own `BasicRateLimiter` configuration. **Note that BasicRateLimiter limits apply per IP address** - so when you configure `RateLimitItemPerMinute(100)`, it means 100 requests per minute per IP address.
```python
from limits import RateLimitItemPerMinute, RateLimitItemPerHour
from limits.storage import RedisStorage
from limits.strategies import SlidingWindowCounterRateLimiter
# Example with Redis storage and custom limits
class CustomAuthorizationPolicy(p.ProductionAuthorizationPolicy):
def __init__(self, ...):
super().__init__()
# ...
self.default_limiter = p.BasicRateLimiter(
rate_limit_item_per_operation={
# Use the default rate limit for most operations
**self.default_limiter.rate_limit_item_per_operation,
# Override specific operations with custom limits
p.Operation.READ_SESSION: RateLimitItemPerMinute(200),
p.Operation.LIST_EVENTS: RateLimitItemPerMinute(1000),
},
# Use a custom storage backend (e.g., Redis)
storage=RedisStorage("redis://localhost:6379"),
# Use a custom window strategy
limiter_type=SlidingWindowCounterRateLimiter,
)
```
The `BasicRateLimiter` uses the `limits` library and supports:
- **Rate limit items**: `RateLimitItemPerMinute(n)`, `RateLimitItemPerSecond(n)`, `RateLimitItemPerHour(n)`
- **Storage options**: `RedisStorage()`, `MemoryStorage()`, and others from the limits library
- **Limiter strategies**: `MovingWindowRateLimiter`, `FixedWindowRateLimiter`, `SlidingWindowCounterRateLimiter`
For complete control, you can implement your own `RateLimiter` from scratch by subclassing the abstract `RateLimiter` class and assigning it to `self.default_limiter`.
### 2. Custom Limiter Functions for Specific Operations
Use `self.specific_limiters` to provide custom rate limiting functions for particular operations. These are functions that take a request and operation and return a boolean indicating whether the rate is within the limit.
```python
class CustomAuthorizationPolicy(p.ProductionAuthorizationPolicy):
def __init__(self, ...):
super().__init__()
# ...
self.specific_limiters[p.Operation.DELETE_AGENT] = self._custom_delete_limiter
async def _custom_delete_limiter(
self,
request: fastapi.Request,
operation: p.Operation
) -> bool:
# Implement your custom logic here
...
```
If you need complete control over both permission checking and rate limiting, you can also subclass the abstract `AuthorizationPolicy` directly and implement all methods from scratch. This gives you full flexibility but requires more implementation work. The approach shown above is recommended for most use cases as it builds on the robust foundation of `ProductionAuthorizationPolicy`.
## Integrating Your Custom Authorization Policy
### Using configure_container
Integrate your custom authorization policy and rate limiter with your Parlant agent:
```python
async def configure_container(
container: p.Container
) -> p.Container:
container[p.AuthorizationPolicy] = CustomAuthorizationPolicy(
secret_key="your-jwt-secret-key",
algorithm="HS256",
)
return container
```
```python
async def main():
# Create Parlant server with custom authorization
async with p.Server(
configure_container=configure_container,
) as server:
# Your agent logic here
await server.serve()
if __name__ == "__main__":
asyncio.run(main())
```
+597
View File
@@ -0,0 +1,597 @@
# Custom Frontend
The fastest way to integrate Parlant into your React application is using our official [`parlant-chat-react`](https://github.com/emcie-co/parlant-chat-react) widget. This component provides a complete chat interface that connects directly to your Parlant agents.
### Installation and Basic Setup
Install the widget via npm or yarn:
```bash
npm install parlant-chat-react
# or
yarn add parlant-chat-react
```
Then integrate it into your React application:
```jsx
import React from 'react';
import ParlantChatbox from 'parlant-chat-react';
function App() {
return (
<div>
<h1>My Application</h1>
<ParlantChatbox
server="http://localhost:8800" // Your Parlant server URL
agentId="your-agent-id" // Your agent's ID
/>
</div>
);
}
export default App;
```
### Configuration Options
The widget supports several configuration props:
```jsx
<ParlantChatbox
// Required props
server="http://localhost:8800"
agentId="your-agent-id"
// Optional props
sessionId="existing-session-id" // Continue existing session
customerId="customer-123" // Associate with specific customer
float={true} // Display as floating popup
titleFn={(session) => `Chat ${session.id}`} // Dynamic title generation
/>
```
### Common Customizations
#### Styling with Custom Classes
Customize the appearance using CSS class overrides:
```jsx
<ParlantChatbox
server="http://localhost:8800"
agentId="your-agent-id"
classNames={{
chatboxWrapper: "my-chat-wrapper",
chatbox: "my-chatbox",
messagesArea: "my-messages",
agentMessage: "my-agent-bubble",
customerMessage: "my-customer-bubble",
textarea: "my-input-field",
popupButton: "my-popup-btn"
}}
/>
```
#### Custom Component Replacement
Replace specific components with your own:
```jsx
<ParlantChatbox
server="http://localhost:8800"
agentId="your-agent-id"
components={{
popupButton: ({ toggleChatOpen }) => (
<button
onClick={toggleChatOpen}
className="custom-chat-button"
>
💬 Chat with us
</button>
),
agentMessage: ({ message }) => (
<div className="custom-agent-message">
<img src="https://parlant.io/agent-avatar.png" alt="Agent" />
<p>{message.data.message}</p>
</div>
)
}}
/>
```
#### Floating Chat Mode
Enable popup mode for a floating chat interface:
```jsx
<ParlantChatbox
server="http://localhost:8800"
agentId="your-agent-id"
float={true}
popupButton={<ChatIcon size={24} color="white" />}
/>
```
> **Reference Implementation**
>
> The parlant-chat-react widget is open source! You can [examine its implementation on GitHub](https://github.com/emcie-co/parlant-chat-react) as a reference for creating custom widgets in other UI frameworks like Vue, Angular, or vanilla JavaScript. The source code demonstrates best practices for session management, event handling, and UI state synchronization.
## Building a Custom Frontend
If you need more control than the React widget provides, or you're using a different framework, you can build a custom frontend using Parlant's client APIs directly.
### Step 1: Initialize the Parlant Client
Start by setting up the Parlant client to communicate with your server:
#### TypeScript
```typescript
import { ParlantClient } from 'parlant-client';
class ParlantChat {
private client: ParlantClient;
private sessionId: string | null = null;
private lastOffset: number = 0;
constructor(serverUrl: string) {
this.client = new ParlantClient({
environment: serverUrl
});
}
}
```
#### JavaScript
```javascript
import { ParlantClient } from 'parlant-client';
class ParlantChat {
constructor(serverUrl) {
this.client = new ParlantClient({
environment: serverUrl
});
this.sessionId = null;
this.lastOffset = 0;
}
}
```
### Step 2: Create a Session
Initialize a conversation session with your agent:
#### TypeScript
```typescript
async createSession(agentId: string, customerId?: string): Promise<string> {
try {
const session = await this.client.sessions.create({
agentId: agentId,
customerId: customerId,
title: `Chat Session ${new Date().toLocaleString()}`
});
this.sessionId = session.id;
console.log('Session created:', this.sessionId);
// Start monitoring for events
this.startEventMonitoring();
return this.sessionId;
} catch (error) {
console.error('Failed to create session:', error);
throw error;
}
}
```
#### JavaScript
```javascript
async createSession(agentId, customerId) {
try {
const session = await this.client.sessions.create({
agentId: agentId,
customerId: customerId,
title: `Chat Session ${new Date().toLocaleString()}`
});
this.sessionId = session.id;
console.log('Session created:', this.sessionId);
// Start monitoring for events
this.startEventMonitoring();
return this.sessionId;
} catch (error) {
console.error('Failed to create session:', error);
throw error;
}
}
```
### Step 3: Send Customer Messages
Handle user input and send messages to the agent:
#### TypeScript
```typescript
async sendMessage(message: string): Promise<void> {
if (!this.sessionId) {
throw new Error('No active session');
}
try {
await this.client.sessions.createEvent(this.sessionId, {
kind: "message",
source: "customer",
message: message
});
// Message will appear in UI when it comes back from event monitoring
console.log('Message sent:', message);
} catch (error) {
console.error('Failed to send message:', error);
throw error;
}
}
```
#### JavaScript
```javascript
async sendMessage(message) {
if (!this.sessionId) {
throw new Error('No active session');
}
try {
await this.client.sessions.createEvent(this.sessionId, {
kind: "message",
source: "customer",
message: message
});
// Message will appear in UI when it comes back from event monitoring
console.log('Message sent:', message);
} catch (error) {
console.error('Failed to send message:', error);
throw error;
}
}
```
### Step 4: Monitor Session Events
Implement event monitoring to receive messages and updates:
#### TypeScript
```typescript
private async startEventMonitoring(): Promise<void> {
if (!this.sessionId) return;
while (true) {
try {
// Poll for new events with long polling
const events = await this.client.sessions.listEvents(this.sessionId, {
minOffset: this.lastOffset,
waitForData: 30, // Wait up to 30 seconds for new events
kinds: ["message", "status"] // Only get message and status events
});
// Process each event
for (const event of events) {
await this.handleEvent(event);
this.lastOffset = Math.max(this.lastOffset, event.offset + 1);
}
} catch (error) {
console.error('Event monitoring error:', error);
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}
private async handleEvent(event: any): Promise<void> {
if (event.kind === "message") {
this.displayMessage(event);
} else if (event.kind === "status") {
this.updateStatus(event.data.status);
}
}
```
#### JavaScript
```javascript
async startEventMonitoring() {
if (!this.sessionId) return;
while (true) {
try {
// Poll for new events with long polling
const events = await this.client.sessions.listEvents(this.sessionId, {
minOffset: this.lastOffset,
waitForData: 30, // Wait up to 30 seconds for new events
kinds: ["message", "status"] // Only get message and status events
});
// Process each event
for (const event of events) {
await this.handleEvent(event);
this.lastOffset = Math.max(this.lastOffset, event.offset + 1);
}
} catch (error) {
console.error('Event monitoring error:', error);
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}
async handleEvent(event) {
if (event.kind === "message") {
this.displayMessage(event);
} else if (event.kind === "status") {
this.updateStatus(event.data.status);
}
}
```
### Step 5: Display Messages in Your UI
Implement UI updates based on events from Parlant:
#### TypeScript
```typescript
private displayMessage(event: any): void {
const messageElement = document.createElement('div');
messageElement.className = `message ${event.source}`;
// Style based on message source
switch (event.source) {
case 'customer':
messageElement.classList.add('customer-message');
break;
case 'ai_agent':
messageElement.classList.add('agent-message');
break;
case 'human_agent':
messageElement.classList.add('human-agent-message');
const agentName = event.data.participant?.display_name || 'Agent';
messageElement.innerHTML = `
<div class="agent-info">${agentName}</div>
<div class="message-content">${event.data.message}</div>
`;
break;
}
// Add to chat container
const chatContainer = document.getElementById('chat-messages');
if (chatContainer) {
chatContainer.appendChild(messageElement);
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}
private updateStatus(status: string): void {
const statusElement = document.getElementById('chat-status');
if (statusElement) {
switch (status) {
case 'processing':
statusElement.textContent = 'Agent is thinking...';
break;
case 'typing':
statusElement.textContent = 'Agent is typing...';
break;
case 'ready':
statusElement.textContent = '';
break;
}
}
}
```
#### JavaScript
```javascript
displayMessage(event) {
const messageElement = document.createElement('div');
messageElement.className = `message ${event.source}`;
// Style based on message source
switch (event.source) {
case 'customer':
messageElement.classList.add('customer-message');
messageElement.innerHTML = `<div class="message-content">${event.data.message}</div>`;
break;
case 'ai_agent':
messageElement.classList.add('agent-message');
messageElement.innerHTML = `<div class="message-content">${event.data.message}</div>`;
break;
case 'human_agent':
messageElement.classList.add('human-agent-message');
const agentName = event.data.participant?.display_name || 'Agent';
messageElement.innerHTML = `
<div class="agent-info">${agentName}</div>
<div class="message-content">${event.data.message}</div>
`;
break;
}
// Add to chat container
const chatContainer = document.getElementById('chat-messages');
if (chatContainer) {
chatContainer.appendChild(messageElement);
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}
updateStatus(status) {
const statusElement = document.getElementById('chat-status');
if (statusElement) {
switch (status) {
case 'processing':
statusElement.textContent = 'Agent is thinking...';
break;
case 'typing':
statusElement.textContent = 'Agent is typing...';
break;
case 'ready':
statusElement.textContent = '';
break;
}
}
}
```
### Step 6: Complete HTML Example
Here's a complete HTML page that demonstrates the custom implementation:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Parlant Chat</title>
<style>
.chat-container {
max-width: 500px;
margin: 50px auto;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.chat-header {
background: #007bff;
color: white;
padding: 15px;
text-align: center;
}
.chat-messages {
height: 400px;
padding: 15px;
overflow-y: auto;
background: #f8f9fa;
}
.message {
margin: 10px 0;
padding: 10px;
border-radius: 8px;
max-width: 80%;
}
.customer-message {
background: #007bff;
color: white;
margin-left: auto;
text-align: right;
}
.agent-message {
background: white;
border: 1px solid #ddd;
}
.human-agent-message {
background: #28a745;
color: white;
}
.chat-input {
display: flex;
padding: 15px;
background: white;
}
.chat-input input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
margin-right: 10px;
}
.chat-input button {
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
#chat-status {
font-style: italic;
color: #666;
padding: 5px 15px;
}
</style>
</head>
<body>
<div class="chat-container">
<div class="chat-header">
<h3>Customer Support Chat</h3>
</div>
<div id="chat-status"></div>
<div id="chat-messages" class="chat-messages"></div>
<div class="chat-input">
<input
type="text"
id="message-input"
placeholder="Type your message..."
onkeypress="handleKeyPress(event)"
/>
<button onclick="sendMessage()">Send</button>
</div>
</div>
<script type="module">
import { ParlantClient } from 'https://unpkg.com/parlant-client@latest/dist/index.js';
// Initialize your custom chat
const chat = new ParlantChat('http://localhost:8800');
// Start chat session
chat.createSession('your-agent-id')
.then(sessionId => {
console.log('Chat ready!', sessionId);
})
.catch(error => {
console.error('Failed to start chat:', error);
});
// Make functions available globally
window.sendMessage = () => chat.sendUserMessage();
window.handleKeyPress = (event) => {
if (event.key === 'Enter') {
chat.sendUserMessage();
}
};
</script>
</body>
</html>
```
### Key Implementation Principles
1. **Event-Driven Architecture**: The chat is driven by events from Parlant sessions, ensuring consistency with the server state.
2. **Long Polling**: Use `waitForData` parameter in `listEvents()` for efficient real-time updates without constant polling.
3. **State Synchronization**: Always display what comes from Parlant events rather than optimistically updating the UI.
4. **Error Handling**: Implement robust error handling and retry logic for network issues.
5. **Responsive Design**: Ensure your chat interface works well on both desktop and mobile devices.
This approach gives you complete control over the chat experience while leveraging Parlant's powerful agent capabilities. You can adapt this pattern to any frontend framework or vanilla JavaScript implementation.
+325
View File
@@ -0,0 +1,325 @@
# Human Handoff
Human handoff is a crucial aspect of customer service automation, especially when using AI agents. It allows for a smooth transition from automated responses to human expertise when necessary. This guide will walk you through the process of implementing human handoff in Parlant.
### The Call Center Model: Understanding Tier Structure
Modern call centers operate on a tiered system designed for efficiency and cost optimization. **Tier 1** representatives handle the majority of calls—typically 80% of customer inquiries—dealing with common issues like account questions, basic troubleshooting, and standard requests. These representatives are trained on frequently asked questions and standard procedures.
**Tier 2** representatives are more experienced and handle complex cases that require specialized knowledge, escalated issues, or nuanced problem-solving. While Tier 2 agents are more skilled and capable, they're also significantly more expensive to employ and maintain.
## Parlant's Approach
Our belief at Parlant, having worked deeply with generative AI, is that AI agents today can effectively automate most **Tier 1 work**, potentially reducing 80% of the customer service workforce while handling inquiries with:
- Professional communication standards
- High efficiency and 24/7 availability
- Graceful conversation management
- Full compliance with business rules
While Parlant's mission includes eventually automating Tier 2 use cases, we honestly believe the technology isn't quite there yet for the most complex scenarios. However, **automating Tier 1 is where the most significant cost savings and efficiency improvements can be achieved**.
## Integrated Human Handoff
Since we're automating Tier 1 requests, it makes sense to support human handoff (essentially to Tier 2) in an integrated way. Parlant allows you to seamlessly integrate with whatever external system you use—such as HubSpot, Zendesk, or custom support platforms.
Here's how to implement human handoff in Parlant:
## Setting Session to Manual Mode
The first step in human handoff is to stop the AI agent from automatically responding to new messages. You can accomplish this by setting the session to manual mode using a tool whenever the right conditions are met (e.g., when the AI agent cannot adequately assist the customer).:
### Using a Tool to Trigger Manual Mode
```python
@p.tool
async def initiate_human_handoff(context: p.ToolContext, reason: str) -> p.ToolResult:
"""Initiate handoff to a human agent when the AI cannot adequately help the customer."""
# Set session to manual mode to stop automatic AI responses
return p.ToolResult(
data=f"Human handoff initiated because: {reason}",
control={
"mode": "manual" # This stops automatic agent responses
}
)
```
```python
# Associate the tool with a guideline
await agent.create_guideline(
condition="Customer requests human assistance",
action="Initiate human handoff and explain the transition professionally",
tools=[initiate_human_handoff]
)
```
### Manually Taking Over a Session
You can also manually set a session to manual mode from an external system using the Parlant client SDKs:
```typescript
// Using the Parlant client to manually set session mode
import { ParlantClient } from 'parlant-client';
const client = new ParlantClient({
environment: "http://localhost:8800" // Your Parlant server URL
});
async function setSessionToManual(sessionId: string) {
await client.sessions.update(sessionId, {
mode: "manual" // Stops automatic AI responses
});
console.log(`Session ${sessionId} set to manual mode`);
}
```
## Managing Session Events Manually
Once a session is in manual mode, you can manage events manually using Parlant's REST API and client SDKs:
### Adding Human Operator Messages
#### Python
```python
from parlant.client import AsyncParlantClient
client = AsyncParlantClient(base_url="http://localhost:8800")
# Add message from human operator
async def send_human_message(session_id: str, message: str, operator_name: str):
event = await client.sessions.create_event(
session_id=session_id,
kind="message",
source="human_agent", # Message from human operator
message=message,
participant={
"id": OPTIONAL_ID_FOR_EXTERNAL_SYSTEM_REFERENCE,
"display_name": operator_name
}
)
return event
```
#### TypeScript
```typescript
import { ParlantClient } from 'parlant-client';
const client = new ParlantClient({
environment: "http://localhost:8800"
});
// Add message from human operator
async function sendHumanMessage(sessionId: string, message: string, operatorName: string) {
const event = await client.sessions.createEvent(sessionId, {
kind: "message",
source: "human_agent", // Message from human operator
message: message,
participant: {
id: OPTIONAL_ID_FOR_EXTERNAL_SYSTEM_REFERENCE,
display_name: operatorName
}
});
return event;
}
```
### Adding Messages on Behalf of AI Agent
Sometimes human operators may want to send messages that appear to come from the AI agent:
#### Python
```python
# Send message on behalf of AI agent
async def send_message_as_ai(session_id: str, message: str):
event = await client.sessions.create_event(
session_id=session_id,
kind="message",
source="human_agent_on_behalf_of_ai_agent", # Human sending as AI
message=message
)
return event
```
#### TypeScript
```typescript
// Send message on behalf of AI agent
async function sendMessageAsAI(sessionId: string, message: string) {
const event = await client.sessions.createEvent(sessionId, {
kind: "message",
source: "human_agent_on_behalf_of_ai_agent", // Human sending as AI
message: message
});
return event;
}
```
## Receiving Events from Parlant
To integrate with external systems, you need to monitor Parlant sessions for new events:
### Event Polling Pattern - Parlant as Single Source of Truth
The key to proper integration is treating Parlant sessions as the **single source of truth** for conversation state. Only read events from Parlant and update your external system accordingly. Even when you send events to Parlant, wait for them to be returned from `list_events()` before showing them in your external system UI.
#### Python
```python
async def monitor_session_events(session_id: str, last_offset: int = 0):
"""
Poll for new events in a Parlant session.
Parlant session is the single source of truth - all message display
should be based on events returned from list_events().
"""
while True:
try:
# Wait for new events with timeout
events = await client.sessions.list_events(
session_id=session_id,
kinds="message",
min_offset=last_offset,
wait_for_data=30 # Wait up to 30 seconds for new events
)
for event in events:
# Process ALL message events from Parlant
await process_event_for_display(event)
last_offset = max(last_offset, event.offset + 1)
except Exception as e:
# Try again after timeout from list_events()
continue
async def process_event_for_display(event):
"""
Process incoming events from Parlant for display in external system.
"""
# Display all message events in external system chat UI
await update_external_chat_display(
message=event.data.get('message'),
source=event.source,
participant_name=event.data.get('participant', {}).get('display_name', 'Unknown'),
timestamp=event.created_at,
event_id=event.id
)
async def update_external_chat_display(message: str, source: str, participant_name: str,
timestamp: str, event_id: str):
"""Update the chat UI in your external system (HubSpot, Zendesk, etc.)"""
# Map Parlant sources to your UI display logic
if source == "customer":
await add_customer_message_to_ui(message, timestamp, event_id)
elif source == "ai_agent":
await add_ai_message_to_ui(message, timestamp, event_id)
elif source == "human_agent":
await add_human_agent_message_to_ui(message, participant_name, timestamp, event_id)
elif source == "human_agent_on_behalf_of_ai_agent":
# Display as AI message but track that human sent it
await add_ai_message_to_ui(message, timestamp, event_id, sent_by_human=True)
```
#### TypeScript
```typescript
async function monitorSessionEvents(sessionId: string, lastOffset: number = 0): Promise<void> {
// Parlant session is the single source of truth for conversation state
while (true) {
try {
// Poll for new events from Parlant
const events = await client.sessions.listEvents(sessionId, {
minOffset: lastOffset,
kinds: "message",
waitForData: 30 // Wait up to 30 seconds
});
for (const event of events) {
// Process ALL message events for display
await processEventForDisplay(event);
lastOffset = Math.max(lastOffset, event.offset + 1);
}
} catch (error) {
// Try again after timeout from listEvents()
}
}
}
async function processEventForDisplay(event: any): Promise<void> {
/**
* Process events from Parlant for display in external system.
*/
await updateExternalChatDisplay({
message: event.data.message,
source: event.source,
participantName: event.data.participant?.display_name,
timestamp: event.createdAt,
eventId: event.id
});
}
interface DisplayMessageParams {
message: string;
source: string;
participantName: string;
timestamp: string;
eventId: string;
}
async function updateExternalChatDisplay(params: DisplayMessageParams): Promise<void> {
/**
* Update the chat UI in your external system (HubSpot, Zendesk, etc.)
* based on what Parlant shows as the authoritative conversation state.
*/
const { message, source, participantName, timestamp, eventId } = params;
switch (source) {
case "customer":
await addCustomerMessageToUI(message, timestamp, eventId);
break;
case "ai_agent":
await addAIMessageToUI(message, timestamp, eventId);
break;
case "human_agent":
await addHumanAgentMessageToUI(message, participantName, timestamp, eventId);
break;
case "human_agent_on_behalf_of_ai_agent":
// Display as AI message but track that human sent it
await addAIMessageToUI(message, timestamp, eventId, true);
break;
}
}
async function addCustomerMessageToUI(message: string, timestamp: string, eventId: string): Promise<void> {
// Implement your UI update logic for customer messages
}
async function addAIMessageToUI(message: string, timestamp: string, eventId: string, sentByHuman: boolean = false): Promise<void> {
// Implement your UI update logic for AI messages
}
async function addHumanAgentMessageToUI(message: string, agentName: string, timestamp: string, eventId: string): Promise<void> {
// Implement your UI update logic for human agent messages
}
```
## Best Practices for Human Handoff
1. **Clear Transition Messages**: Always inform customers when they're being transferred to a human agent and explain why. You can achieve this using guidelines.
2. **Context Preservation**: Ensure human agents have access to the full conversation history from the AI interaction. Do this by synchronizing the session's events with your external system.
3. **Seamless Experience**: Use `human_agent_on_behalf_of_ai_agent` source when you want to maintain the illusion of a single agent experience. Customers don't always need to know they're interacting with a human.
4. **Monitoring and Analytics**: Track handoff rates, reasons, and resolution outcomes to improve your AI agent over time. Implement lessons learned from human interactions to refine agent responses and guidelines.
5. **Return to AI**: Consider implementing logic to return sessions to automatic mode when appropriate.
This integration approach allows you to leverage AI agents for efficient Tier 1 support while ensuring complex issues can be seamlessly escalated to human experts, providing the best of both worlds for customer service.
+95
View File
@@ -0,0 +1,95 @@
# User-Input Moderation
Adding content filtering to your AI agents helps achieve a more professional level of customer interactions. Here's why it matters.
### Understanding the Challenges
AI agents, being based on LLMs, are statistical pattern matchers that can be influenced by the nature of inputs they receive. Think of them like customer service representatives who benefit from clear boundaries about what conversations they should and shouldn't engage in.
#### Sensitive Topics
Some topics, like mental health or illicit activities, require professional human handling. While your agent might technically handle these topics, in practical use cases it's often better for it to avoid such conversations, or even redirect them to appropriate human resources.
#### Protection from Harassment
Customer interactions should remain professional, but some users might attempt to harass or abuse the agent (or others). This isn't just about maintaining decorum: LLMs (like humans) can in some cases be influenced by aggressive or inappropriate language, potentially affecting their responses.
To address such cases, Parlant integrates with moderation APIs, such as [OpenAI's Omni Moderation](https://openai.com/index/upgrading-the-moderation-api-with-our-new-multimodal-moderation-model/), to filter such interactions before they reach your agent.
### Enabling Input Moderation
To enable moderation, all you need to do is set a query parameter when creating events.
#### Python
```python
from parlant.client import ParlantClient
client = ParlantClient(base_url=SERVER_ADDRESS)
client.sessions.create_event(
SESSION_ID,
kind="message",
source="customer",
message=MESSAGE,
moderation="auto",
)
```
#### TypeScript
```typescript
import { ParlantClient } from 'parlant-client';
const client = new ParlantClient({ environment: SERVER_ADDRESS });
await client.sessions.createEvent(SESSION_ID, {
kind: "message",
source: "customer",
message: MESSAGE,
moderation: "auto",
});
```
When customers send inappropriate messages, Parlant ensures that their content is not even visible to the agent; rather, all the agent sees is that a customer sent a message which has been "censored" for a some specific reason (e.g. harrassment, illicit behavior, etc.).
This integrates well with guidelines. For example, you may install a guideline such as:
> * **Condition:** the customer's last message is censored
> * **Action:** inform them that you can't help them with this query, and suggest they contact human support
From a UX perspective, this approach is superior to just "erroring out" when encountering such messages. Instead of seeing an error, the customer gets a polite and informative response. Better yet, the response can be controlled with guidelines and tools just as in any other situation.
## Jailbreak Protection
While your agent's guidelines aren't strictly security measures (as that's handled more robustly by backend permissions), maintaining presentable behavior is important even when some users might try to trick the agent into revealing its instructions or acting outside its intended boundaries.
Parlant's moderation system supports a special `paranoid` mode, which integrates with [Lakera Guard](https://www.lakera.ai/lakera-guard) (from the creators of the [Gandalf Challenge](https://gandalf.lakera.ai/baseline)) to prevent such manipulation attempts.
#### Python
```python
from parlant.client import ParlantClient
client = ParlantClient(base_url=SERVER_ADDRESS)
client.sessions.create_event(
SESSION_ID,
kind="message",
source="customer",
message=MESSAGE,
moderation="paranoid",
)
```
#### TypeScript
```typescript
import { ParlantClient } from 'parlant-client';
const client = new ParlantClient({ environment: SERVER_ADDRESS });
await client.sessions.createEvent(SESSION_ID, {
kind: "message",
source: "customer",
message: MESSAGE,
moderation: "paranoid",
});
```
Note that to activate `paranoid` mode, you need to get an API key from Lakera and assign it to the environment variable `LAKERA_API_KEY` before starting the server.
+383
View File
@@ -0,0 +1,383 @@
# Healthcare Agent Example
This page walks you through using Parlant to design and build a healthcare agent with two customer journeys.
1. **Schedule an appointment**: The agent helps the patient find a time for their appointment.
1. **Lab results**: The agent retrieves the patient's lab results and explains them.
![Scheduling journey demo](https://parlant.io/img/example-scheduling-journey.gif)
You'll learn how to:
- Align your agent with basic domain knowledge.
- Define **journeys** with **states** and **transitions**.
- Use **guidelines** to control the agent's behavior in conversational edge cases.
- Use **tools** to connect your agent to real actions and data.
- Disambiguate vague user queries.
While this section is by no means a comprehensive guide to Parlant's features, it will give you a solid idea of what the basics look like, and how to think about building your own agents with Parlant. Let's get started!
> **Info: The Art of Behavior Modeling**
>
> Building complex and reliable customer-facing AI agents is a challenging task. Don't let the hype-machine tell you otherwise.
>
> It isn't just about having the right framework. When we automate conversations, we are automating the complex semantics of human conversations. In very real terms, this means we need to design our instructions and behavior models carefully. They need to be clear, and be at the right level of specificity, to ensure that the agent truly behaves as we expect it to.
>
> While Parlant gives you the tools to express and enforce your instructions, _designing them_ is an art in itself, requiring practice to get right. But once you do, you can build agents that are not only functional and reliable, but also engaging and effective.
## Preparing the Environment
Before getting started, make sure you've
1. [Installed](https://parlant.io/docs/quickstart/installation) Parlant and have a Python environment set up.
1. Chosen your NLP provider and connected it to your server (also on the [installation page](https://parlant.io/docs/quickstart/installation)).
> **Tip: Download the Code**
>
> The runnable code for this fully worked example can be found in the `examples/` folder of [Parlant's GitHub repository](https://github.com/emcie-co/parlant).
## Overview
We'll implement the agent in the following steps:
1. Create the baseline program with a simple agent description.
1. Add the **scheduling** journey, with states, transitions, and tools.
1. Add the **lab results** journey in a similar way.
## Getting Started
We'll implement the entire program in a single file, `healthcare.py`, but in real-world use cases you would likely want to split it into multiple files for better organization. A good approach in those cases is to have a file per journey.
But now let's get to creating our initial agent.
```python
# healthcare.py
import parlant.sdk as p
import asyncio
async def add_domain_glossary(agent: p.Agent) -> None:
await agent.create_term(
name="Office Phone Number",
description="The phone number of our office, at +1-234-567-8900",
)
await agent.create_term(
name="Office Hours",
description="Office hours are Monday to Friday, 9 AM to 5 PM",
)
await agent.create_term(
name="Charles Xavier",
synonyms=["Professor X"],
description="The renowned doctor who specializes in neurology",
)
# Add other specific terms and definitions here, as needed...
async def main() -> None:
async with p.Server() as server:
agent = await server.create_agent(
name="Healthcare Agent",
description="Is empathetic and calming to the patient.",
)
await add_domain_glossary(agent)
if __name__ == "__main__":
asyncio.run(main())
```
## Creating the Scheduling Journey
To understand how journeys work in Parlant, please check out the [Journeys documentation](https://parlant.io/docs/concepts/customization/journeys). Here, we'll jump straight into it, but it's recommended to review their documentation first.
### Adding Tools
First, add the tools we need to support this journey.
```python
from datetime import datetime
@p.tool
async def get_upcoming_slots(context: p.ToolContext) -> p.ToolResult:
# Simulate fetching available times from a database or API
return p.ToolResult(data=["Monday 10 AM", "Tuesday 2 PM", "Wednesday 1 PM"])
@p.tool
async def get_later_slots(context: p.ToolContext) -> p.ToolResult:
# Simulate fetching later available times
return p.ToolResult(data=["November 3, 11:30 AM", "November 12, 3 PM"])
@p.tool
async def schedule_appointment(context: p.ToolContext, datetime: datetime) -> p.ToolResult:
# Simulate scheduling the appointment
return p.ToolResult(data=f"Appointment scheduled for {datetime}")
```
> **Tip: Tools in Parlant**
>
> Parlant has a more intricate tool system than most agentic frameworks, since it is optimized for conversational, sensitive customer-facing use cases. We highly recommend perusing the documentation in the [Tools section](https://parlant.io/docs/concepts/customization/tools) to learn its power.
### Building the Journey
We'll now create the journey according to the following diagram:
```mermaid
stateDiagram-v2
[*] --> DetermineVisitReason
DetermineVisitReason --> GetUpcomingSlots
GetLaterSlots --> ListLaterAvailableTimes
ListAvailableTimes --> ConfirmDetails : The patient picks a time
GetUpcomingSlots --> ListAvailableTimes
ListLaterAvailableTimes --> ConfirmDetails : The patient picks a time
ListLaterAvailableTimes --> CallOffice : None of those times work for the patient either
ListAvailableTimes --> GetLaterSlots : None of those times work for the patient
ConfirmDetails --> BookAppointment: The patient confirms the details
BookAppointment --> ConfirmBooking : Appointment confirmed
ConfirmBooking --> [*]
CallOffice --> [*]
style GetUpcomingSlots fill:#ffeecc,stroke:#333,stroke-width:1px
style GetLaterSlots fill:#ffeecc,stroke:#333,stroke-width:1px
style BookAppointment fill:#ffeecc,stroke:#333,stroke-width:1px
```
```python
# <<Add this function>>
async def create_scheduling_journey(server: p.Server, agent: p.Agent) -> p.Journey:
# Create the journey
journey = await agent.create_journey(
title="Schedule an Appointment",
description="Helps the patient find a time for their appointment.",
conditions=["The patient wants to schedule an appointment"],
)
# First, determine the reason for the appointment
t0 = await journey.initial_state.transition_to(chat_state="Determine the reason for the visit")
# Load upcoming appointment slots into context
t1 = await t0.target.transition_to(tool_state=get_upcoming_slots)
# Ask which one works for them
# We will transition conditionally from here based on the patient's response
t2 = await t1.target.transition_to(chat_state="List available times and ask which ones works for them")
# We'll start with the happy path where the patient picks a time
t3 = await t2.target.transition_to(
chat_state="Confirm the details with the patient before scheduling",
condition="The patient picks a time",
)
t4 = await t3.target.transition_to(
tool_state=schedule_appointment,
condition="The patient confirms the details",
)
t5 = await t4.target.transition_to(chat_state="Confirm the appointment has been scheduled")
await t5.target.transition_to(state=p.END_JOURNEY)
# Otherwise, if they say none of the times work, ask for later slots
t6 = await t2.target.transition_to(
tool_state=get_later_slots,
condition="None of those times work for the patient",
)
t7 = await t6.target.transition_to(chat_state="List later times and ask if any of them works")
# Transition back to our happy-path if they pick a time
await t7.target.transition_to(state=t3.target, condition="The patient picks a time")
# Otherwise, ask them to call the office
t8 = await t7.target.transition_to(
chat_state="Ask the patient to call the office to schedule an appointment",
condition="None of those times work for the patient either",
)
await t8.target.transition_to(state=p.END_JOURNEY)
return journey
```
Then call this function in your `main` function to add the journey to your agent:
```python
async def main() -> None:
async with p.Server() as server:
agent = await server.create_agent(
name="Healthcare Agent",
description="Is empathetic and calming to the patient.",
)
# <<Add this line>>
scheduling_journey = await create_scheduling_journey(server, agent)
```
### Handling Edge Cases
In real-world scenarios, patients do not always followed the scripted path of your journeys. They might ask questions, express concerns, or provide other unexpected responses.
For Parlant agents, this is their bread and butter! While they will still be able to respond contextually to the patient, you might still like to guide and improve *how* they respond in particular scenarios that you've observed.
To do this, you can add **guidelines** to your agent. Guidelines are like contextual rules that tell the agent how to respond in specific situations. And you can scope them to specific journeys, so they only apply when the agent is in that journey.
Let's add a few guidelines to our agent to handle some common edge cases in the scheduling journey.
```python
async def create_scheduling_journey(server: p.Server, agent: p.Agent) -> p.Journey:
# ... continued
# <<Add this to the end of the create_scheduling_journey function>>
await journey.create_guideline(
condition="The patient says their visit is urgent",
action="Tell them to call the office immediately",
)
# Add more edge case guidelines as needed...
return journey
```
### Running the Program
When you run the program, you should first see Parlant evaluating the semantic properties of your configuration. It does this in order to optimize how your guidelines and journeys are retrieved, processed and followed behind the scenes.
![Evaluation of the agent configuration](https://parlant.io/img/example-evaluation.gif)
Once the server is ready, open your browser and navigate to [http://localhost:8800](http://localhost:8800) to interact with your agent.
![Scheduling journey demo](https://parlant.io/img/example-scheduling-journey.gif)
> **Warning: Handling Unsupported Queries**
>
> You may notice that your agent, at this point, is happy to try and assist customers while completely overstepping the boundaries of its knowledge and capabilities. While this is normal with LLMs, it is untolerable in many real-life use cases.
>
> Parlant provides multiple structured ways to achieve absolute control over your agent's (mis)behavior. This example is only the beginning; rest assured that as you learn more about Parlant, it can help you deploy an agent you can actually trust.
## Creating the Lab Results Journey
We'll speed through this journey, as it will be very similar in structure to the other journey (and any other journey you'd be likely to build).
### Adding Tools
```python
@p.tool
async def get_lab_results(context: p.ToolContext) -> p.ToolResult:
# Simulate fetching lab results from a database or API,
# using the customer ID from the context.
lab_results = await MY_DB.get_lab_results(context.customer_id)
if lab_results is None:
return p.ToolResult(data="No lab results found for this patient.")
return p.ToolResult(data={
"report": lab_results.report,
"prognosis": lab_results.prognosis,
})
```
### Building the Journey
```python
async def create_lab_results_journey(server: p.Server, agent: p.Agent) -> p.Journey:
# Create the journey
journey = await agent.create_journey(
title="Lab Results",
description="Retrieves the patient's lab results and explains them.",
conditions=["The patient wants to see their lab results"],
)
t0 = await journey.initial_state.transition_to(tool_state=get_lab_results)
await t0.target.transition_to(
chat_state="Tell the patient that the results are not available yet, and to try again later",
condition="The lab results could not be found",
)
await t0.target.transition_to(
chat_state="Explain the lab results to the patient - that they are normal",
condition="The lab results are good - i.e., nothing to worry about",
)
await t0.target.transition_to(
chat_state="Present the results and ask them to call the office "
"for clarifications on the results as you are not a doctor",
condition="The lab results are not good - i.e., there's an issue with the patient's health",
)
# Handle edge cases with guidelines...
await agent.create_guideline(
condition="The patient presses you for more conclusions about the lab results",
action="Assertively tell them that you cannot help and they should call the office"
)
return journey
```
Finally, call this function in your `main` function to add the journey to your agent:
```python
async def main() -> None:
async with p.Server() as server:
agent = await server.create_agent(
name="Healthcare Agent",
description="Is empathetic and calming to the patient.",
)
scheduling_journey = await create_scheduling_journey(server, agent)
# <<Add this line>>
lab_results_journey = await create_lab_results_journey(server, agent)
```
Restart the program, open your browser and navigate to [http://localhost:8800](http://localhost:8800) to interact with your agent. Try saying something like, _"Did my lab results come in?"_ or _"I want to schedule an appointment"_.
## Disambiguating Patient Intent
In some cases, the patient might say something that could be interpreted in multiple ways, leading to confusion about which action to take or what they wish to achieve.
An easy way to handle this is to use **disambiguation**. This will get the agent to ask the patient to clarify their intent when multiple actions could be taken. Here's how you can do it:
```python
async def main() -> None:
async with p.Server() as server:
agent = await server.create_agent(
name="Healthcare Agent",
description="Is empathetic and calming to the patient.",
)
scheduling_journey = await create_scheduling_journey(server, agent)
lab_results_journey = await create_lab_results_journey(server, agent)
# <<Add the following lines>>
# First, create an observation of an ambiguous situation
status_inquiry = await agent.create_observation(
"The patient asks to follow up on their visit, but it's not clear in which way",
)
# Use this observation to disambiguate between the two journeys
await status_inquiry.disambiguate([scheduling_journey, lab_results_journey])
```
Now, if the patient inquires in an ambiguous way about a follow-up, the agent will ask them to clarify whether they want to schedule an appointment or see their lab results.
Restart the program, open your browser and navigate to [http://localhost:8800](http://localhost:8800) to interact with your agent. Try saying something like, _"I need to follow up on my last visit"_ and see what the agent responds with.
## Global Guidelines
There are usually some guidelines that you might want to apply to all journeys of your agent, not just a specific one (or, for that matter, even if a patient is not in the middle of a journey). For example, you might want to provide information about insurance providers in an informed manner.
To achieve this, you just need to add guidelines to the agent itself, rather than to a specific journey.
```python
await agent.create_guideline(
condition="The patient asks about insurance",
action="List the insurance providers we accept, and tell them to call the office for more details",
tools=[get_insurance_providers],
)
await agent.create_guideline(
condition="The patient asks to talk to a human agent",
action="Ask them to call the office, providing the phone number",
)
await agent.create_guideline(
condition="The patient inquires about something that has nothing to do with our healthcare",
action="Kindly tell them you cannot assist with off-topic inquiries - do not engage with their request.",
)
```
## Next Steps
1. Download and try out the runnable code file for this example: [healthcare.py](https://github.com/emcie-co/parlant/blob/develop/examples/healthcare.py)
1. Tailor and constrain the content and style of agent messages with canned responses: [Canned Responses](https://parlant.io/docs/concepts/customization/canned-responses)
1. Learn how to deploy your agent in a [production environment](https://parlant.io/docs/category/production)
1. Add the [React widget](https://github.com/emcie-co/parlant-chat-react) to your website to interact with the agent
+181
View File
@@ -0,0 +1,181 @@
# Installation
![Parlant Logo](https://parlant.io/logo/logo-full.svg)
**Parlant** is an open-source **Agentic Behavior Modeling Engine** for LLM agents, built to help developers quickly create customer-engaging, business-aligned conversational agents with control, clarity, and confidence.
It gives you all the structure you need to build customer-facing agents that behave exactly as your business requires:
- **[Journeys](https://parlant.io/docs/concepts/customization/journeys)**:
Define clear customer journeys and how your agent should respond at each step.
- **[Behavioral Guidelines](https://parlant.io/docs/concepts/customization/guidelines)**:
Easily craft agent behavior; Parlant will match the relevant elements contextually.
- **[Tool Use](https://parlant.io/docs/concepts/customization/tools)**:
Attach external APIs, data fetchers, or backend services to specific interaction events.
- **[Domain Adaptation](https://parlant.io/docs/concepts/customization/glossary)**:
Teach your agent domain-specific terminology and craft personalized responses.
- **[Canned Responses](https://parlant.io/docs/concepts/customization/canned-responses)**:
Use response templates to eliminate hallucinations and guarantee style consistency.
- **[Explainability](https://parlant.io/docs/advanced/explainability)**:
Understand why and when each guideline was matched and followed.
## Installation
Parlant is available on both [GitHub](https://github.com/emcie-co/parlant) and [PyPI](https://pypi.org/project/parlant/) and works on multiple platforms (Windows, Mac, and Linux).
Please note that [Python 3.10](https://www.python.org/downloads/release/python-3105/) and up is required for Parlant to run properly.
```bash
pip install parlant
```
If you're feeling adventurous and want to try out new features, you can also install the latest development version directly from GitHub.
```bash
pip install git+https://github.com/emcie-co/parlant@develop
```
## Creating Your First Agent
Once installed, you can use the following code to spin up an initial, sample agent. You'll flesh out its behavior later.
```python
# main.py
import asyncio
import parlant.sdk as p
async def main():
async with p.Server() as server:
agent = await server.create_agent(
name="Otto Carmen",
description="You work at a car dealership",
)
asyncio.run(main())
```
You'll notice Parlant follows the asynchronous programming paradigm with `async` and `await`. This is a powerful feature of Python that lets you to write code that can handle many tasks at once, allowing your agent to handle more concurrent requests in production.
If you're new to async programming, check out the [official Python documentation](https://docs.python.org/3/library/asyncio.html) for a quick introduction.
Parlant uses OpenAI as the default NLP provider, so you need to ensure you have `OPENAI_API_KEY` set in your environment.
Then, run the program!
```bash
export OPENAI_API_KEY="<YOUR_API_KEY>"
python main.py
```
Parlant supports multiple LLM providers by default, accessible via the `p.NLPServices` class. You can also add your own provider by implementing the `p.NLPService` interface, which you can learn how to do in the [Custom NLP Models](https://parlant.io/docs/advanced/custom-llms) section.
To use one of the built-in-providers, you can specify it when creating the server. For example:
```python
async with p.Server(nlp_service=p.NLPServices.cerebras) as server:
...
```
Note that you may need to install an additional "extra" package for some providers. For example, to use the Cerebras NLP service:
```bash
pip install parlant[cerebras]
```
Having said that, Parlant is observed to work best with [OpenAI](https://openai.com) and [Anthropic](https://www.anthropic.com) models, as these models are highly consistent in generating high-quality completions with valid JSON schemas—so we recommend using one of those if you're just starting out.
## Testing Your Agent
To test your installation, head over to [http://localhost:8800](http://localhost:8800) and start a new session with the agent.
![Post installation demo](https://parlant.io/img/post-installation-demo.gif)
## Creating Your First Guideline
Guidelines are the core of Parlant's behavior model. They allow you to define how your agent should respond to specific user inputs or conditions. Parlant cleverly manages guideline context for you, so you can add as many guidelines as you need without worrying about context overload or other scale issues.
```python
# main.py
import asyncio
import parlant.sdk as p
async def main():
async with p.Server() as server:
agent = await server.create_agent(
name="Otto Carmen",
description="You work at a car dealership",
)
##############################
## Add the following: ##
##############################
await agent.create_guideline(
# This is when the guideline will be triggered
condition="the customer greets you",
# This is what the guideline instructs the agent to do
action="offer a refreshing drink",
)
asyncio.run(main())
```
Now re-run the program:
```bash
python main.py
```
Refresh [http://localhost:8800](http://localhost:8800), start a new session, and greet the agent. You should expect to be offered a drink!
## Using the Official React Widget
If your frontend project is built with React, the fastest and easiest way to start is to use the official Parlant React widget to integrate with the server.
Here's a basic code example to get started:
```jsx
import React from 'react';
import ParlantChatbox from 'parlant-chat-react';
function App() {
return (
<div>
<h1>My Application</h1>
<ParlantChatbox
server="PARLANT_SERVER_URL"
agentId="AGENT_ID"
/>
</div>
);
}
export default App;
```
For more documentation and customization, see the **GitHub repo:** https://github.com/emcie-co/parlant-chat-react.
```bash
npm install parlant-chat-react
```
## Installing Client SDK(s)
To create a custom frontend app that interacts with the Parlant server, we recommend installing our native client SDKs. We currently support Python and TypeScript (also works with JavaScript).
#### Python
```bash
pip install parlant-client
```
#### TypeScript/JavaScript
```bash
npm install parlant-client
```
You can review our tutorial on integrating a custom frontend here: [Custom Frontend Integration](https://parlant.io/docs/production/custom-frontend).
For other languages—they are coming soon! Meanwhile you can use the [REST API](https://parlant.io/docs/api/create-agent) directly.
+120
View File
@@ -0,0 +1,120 @@
# Motivation
Let's say you downloaded some agent framework and built an AI agent—that's great! However, when you actually test it, you see it's not handling many customer interactions properly. Your business experts are displeased with it. Your prompts are turning into a mess. What do you do?
Enter the world of **Agentic Behavior Modeling (ABM)**: a new powerful approach to controlling how your agents interact with your users.
A behavior model is a structured, custom-tailored set of principles, actions, objectives, and ground-truths that orientates an agent to a particular domain or use case.
```mermaid
%%{init: { "theme": "neutral" }}%%
mindmap
root((Behavior Model))
Guidelines
Journeys
Tools
Capabilities
Glossary
Variables
Semantic Relationships
Canned Responses
```
#### Why Behavior Modeling?
The problem of getting an LLM agent to say and do what _you_ want it to is a hard one, experienced by virtually anyone building customer-facing agents. Here's how ABM compares to other approaches to solving this problem.
- **Flow engines**, in which you build turn-by-turn conversational flowcharts, _force_ the user to interact according to predefined scripts. This rigid approach tends to lead to poor user engagement and trust. In contrast, an **ABM engine** dynamically _adapts_ to a user's natural interaction patterns while conforming to your business rules.
- **Free-form prompt engineering**, be it with graph-based orchestration or system prompts, frequently leads to _inconsistent and unreliable behavioral conformance_, failing to uphold requirements and expectations. Conversely, an **ABM engine** leverages clear semantical structures and annotations to facilitate conformance to business rules.
```mermaid
%%{init: {"theme": "base", "themeVariables": {
"quadrant1Fill": "#ffffff", "quadrant1TextFill": "#000000",
"quadrant2Fill": "#eeeeee", "quadrant2TextFill": "#000000",
"quadrant3Fill": "#eeeeee", "quadrant3TextFill": "#000000",
"quadrant4Fill": "#eeeeee", "quadrant4TextFill": "#000000",
"primaryBorderColor": "#cccccc"
}}}%%
quadrantChart
title Conversational AI Approaches (Open-Source)
x-axis Low Adaptability --> High Adaptability
y-axis Low Predictability --> High Predictability
quadrant-1 Agentic Behavior Modeling
quadrant-2 NLU-Based Flows
quadrant-3 LLM-Based Flows
quadrant-4 Prompt Engineering / RAG
Parlant: [0.75, 0.75]
Rasa: [0.25, 0.75]
Langflow: [0.15, 0.2]
Botpress: [0.25, 0.3]
n8n: [0.35, 0.2]
LangChain: [0.85, 0.2]
LangGraph: [0.75, 0.3]
LlamaIndex: [0.65, 0.2]
```
## What is Parlant?
Parlant is an open-source **ABM Engine** for LLM agents, which means that you can use it to precisely control how your LLM agent interacts with users in different scenarios.
Parlant is a full-fledged framework, prebuilt with numerous proven features to help you ramp up quickly with customer-facing agents and make the behavior modeling process as easy as possible.
## Why Parlant?
Many conversational AI use cases require strict conformance to business rules when interacting with users. However, until now this has been exceedingly difficult to achieve with LLMs—at least when consistency is a concern.
Parlant was built to solve this challenge. By implementing a structured, developer-friendly approach to modeling conversational behavior, through carefully designed rules, entities, and relationships, Parlant allows you to define, enforce, track, and reason about agent decisions in a simple and elegant manner.
## Behavior Modeling 101: Granular Guidelines
The most basic yet powerful modeling entity in a Behavior Model is the **guideline**. In Parlant, instead of defining your guidelines in free-form fashion (as you might do in a system prompt), you define them in **granular** fashion, where each guideline adds an individual **clarification** that nudges your AI agent on how to approach a particular situation.
To ensure your agent stays focused and consistent conformant to your guidelines, Parlant automatically filters and selects the most relevant set of guidelines for it to apply in any given situation, out of all of the guidelines you provide it. It does this by looking both at a guideline's _condition_ (which describes the circumstances in which it should apply) and its _action_ (describing what it should do).
Finally, it applies enforcement to ensure that the matched guidelines are actually followed, and provides you with explanations for your agent's interpretation of situations and guidelines at every turn.
Working iteratively, adding guidelines wherever you find the need, you can get your LLM agent to approach and handle various different circumstances according to your exact needs and expectations.
```python
await agent.create_guideline(
condition="you have suggested a solution that did not work for the user",
action="ask if they'd prefer to talk to a human agent, or continue troubleshooting with you",
)`,
```
Much of what Parlant does behind the scenes is understanding when a guideline should be applied. This is trickier than it may seem. For example, Parlant automatically keeps track of whether a guideline has already been applied in a conversation, so that it doesn't repeat itself unnecessarily. It also distinguishes between guidelines that are _always_ applicable, and those that are only applicable _once_ in a conversation. And it does this while minimizing cost and latency.
> **AI Behavior Explainability**
>
> Once guidelines are installed, you can get clear feedback regarding their evaluation at every turn by inspecting Parlant's logs.
>
> Learn more about this in the section on how Parlant implements [enforcement & explainability](https://parlant.io/docs/advanced/explainability).
## Understanding the Pain Point
By now, while most people building AI agents know hallucinations are an important challenge, still too few are aware of the practical alignment challenges that come with building effective conversational LLM agents.
Here's the thing. An [LLM](https://en.wikipedia.org/wiki/Large_language_model) is like a stranger with an encyclopedic knowledge of different approaches to every possible situation. Although incredibly powerful, **this combination of extreme versatility and inherent lack of context is precisely why it so rarely behaves as we'd expect**—there are too many viable options for it to choose from.
This is why, without a clear and comprehensive set of [guidelines](https://parlant.io/docs/concepts/customization/guidelines), an LLM will always try to draw optimistically from its vast but unfiltered set of training observations. It will easily end up using tones that are out of touch with the customer or the situation, making irrelevant offers, getting into loops, or just losing focus and going off on tangents.
![Cartoon2](https://parlant.io/img/cartoon_1_1.png)
![Cartoon2](https://parlant.io/img/cartoon_1_2.png)
Behavior modeling is an approach whose goal is to streamline LLM agent guidance. Every time you see your agent missing the mark, you narrow it down to a necessary change in the behavior model, and solve it quickly by adjusting it. You do this primarily using [guidelines](https://parlant.io/docs/concepts/customization/guidelines.mdx), as well as other modeling elements that Parlant supports.
To this end, Parlant is designed from the ground up to allow you to **quickly tune-up your agent's behavior whenever you encounter unexpected behavior** or get feedback from customers and business experts. The result is an effective, controlled, and incremental cycle of improvement.
![Cartoon2](https://parlant.io/img/cartoon_2_1.png)
![Cartoon2](https://parlant.io/img/cartoon_2_2.png)
![Cartoon2](https://parlant.io/img/cartoon_2_3.png)
The informed premise behind Parlant is that [poorly guided AI agents are a dead-end](https://parlant.io/about#the-intrinsic-need-for-guidance). Without guidance, AI agents are bound to encounter numerous ambiguities, and end up trying to resolve them using many incorrect or even problematic approaches. **Only you can authoritatively teach your agent how to make the right choices for you**—so you should be able to do so easily, quickly, and reliably.
Instead of an agent that goes around the bush, meanders, and offers irrelevant solutions or answers, **Parlant helps you build an agent that is guided, focused, and feels well-designed**—one that your customers would actually use.
![Cartoon3](https://parlant.io/img/cartoon_1_1.png)
![Cartoon3](https://parlant.io/img/cartoon_3_2.png)
So pack your bags and get ready to model some awesome AI conversations. You've got the controls now. Let's start!
+201
View File
@@ -0,0 +1,201 @@
# healthcare.py
import parlant.sdk as p
import asyncio
from datetime import datetime
@p.tool
async def get_insurance_providers(context: p.ToolContext) -> p.ToolResult:
return p.ToolResult(["Mega Insurance", "Acme Insurance"])
@p.tool
async def get_upcoming_slots(context: p.ToolContext) -> p.ToolResult:
# Simulate fetching available times from a database or API
return p.ToolResult(data=["Monday 10 AM", "Tuesday 2 PM", "Wednesday 1 PM"])
@p.tool
async def get_later_slots(context: p.ToolContext) -> p.ToolResult:
# Simulate fetching later available times
return p.ToolResult(data=["November 3, 11:30 AM", "November 12, 3 PM"])
@p.tool
async def schedule_appointment(context: p.ToolContext, datetime: datetime) -> p.ToolResult:
# Simulate scheduling the appointment
return p.ToolResult(data=f"Appointment scheduled for {datetime}")
@p.tool
async def get_lab_results(context: p.ToolContext) -> p.ToolResult:
# Simulate fetching lab results from a database or API,
# using the customer ID from the context.
lab_results = {
"report": "All tests are within the valid range",
"prognosis": "Patient is healthy as a horse!",
}
return p.ToolResult(
data={
"report": lab_results["report"],
"prognosis": lab_results["prognosis"],
}
)
async def add_domain_glossary(agent: p.Agent) -> None:
await agent.create_term(
name="Office Phone Number",
description="The phone number of our office, at +1-234-567-8900",
)
await agent.create_term(
name="Office Hours",
description="Office hours are Monday to Friday, 9 AM to 5 PM",
)
await agent.create_term(
name="Charles Xavier",
synonyms=["Professor X"],
description="The doctor who specializes in neurology and is available on Mondays and Tuesdays.",
)
# Add other specific terms and definitions here, as needed...
# <<Add this function>>
async def create_scheduling_journey(server: p.Server, agent: p.Agent) -> p.Journey:
# Create the journey
journey = await agent.create_journey(
title="Schedule an Appointment",
description="Helps the patient find a time for their appointment.",
triggers=["The patient wants to schedule an appointment"],
)
# First, determine the reason for the appointment
t0 = await journey.initial_state.transition_to(chat_state="Determine the reason for the visit")
# Load upcoming appointment slots into context
t1 = await t0.target.transition_to(tool_state=get_upcoming_slots)
# Ask which one works for them
# We will transition conditionally from here based on the patient's response
t2 = await t1.target.transition_to(
chat_state="List available times and ask which ones works for them"
)
# We'll start with the happy path where the patient picks a time
t3 = await t2.target.transition_to(
chat_state="Confirm the details with the patient before scheduling",
condition="The patient picks a time",
)
t4 = await t3.target.transition_to(
tool_state=schedule_appointment,
condition="The patient confirms the details",
)
t5 = await t4.target.transition_to(chat_state="Confirm the appointment has been scheduled")
await t5.target.transition_to(state=p.END_JOURNEY)
# Otherwise, if they say none of the times work, ask for later slots
t6 = await t2.target.transition_to(
tool_state=get_later_slots,
condition="None of those times work for the patient",
)
t7 = await t6.target.transition_to(chat_state="List later times and ask if any of them works")
# Transition back to our happy-path if they pick a time
await t7.target.transition_to(state=t3.target, condition="The patient picks a time")
# Otherwise, ask them to call the office
t8 = await t7.target.transition_to(
chat_state="Ask the patient to call the office to schedule an appointment",
condition="None of those times work for the patient either",
)
await t8.target.transition_to(state=p.END_JOURNEY)
# Handle edge-cases deliberately with guidelines
await journey.create_guideline(
condition="The patient says their visit is urgent",
action="Tell them to call the office immediately",
)
return journey
async def create_lab_results_journey(server: p.Server, agent: p.Agent) -> p.Journey:
# Create the journey
journey = await agent.create_journey(
title="Lab Results",
description="Retrieves the patient's lab results and explains them.",
triggers=["The patient wants to see their lab results"],
)
t0 = await journey.initial_state.transition_to(tool_state=get_lab_results)
await t0.target.transition_to(
chat_state="Tell the patient that the results are not available yet, and to try again later",
condition="The lab results could not be found",
)
await t0.target.transition_to(
chat_state="Explain the lab results to the patient - that they are normal",
condition="The lab results are good - i.e., nothing to worry about",
)
await t0.target.transition_to(
chat_state="Present the results and ask them to call the office "
"for clarifications on the results as you are not a doctor",
condition="The lab results are not good - i.e., there's an issue with the patient's health",
)
# Handle edge cases with guidelines...
await agent.create_guideline(
condition="The patient presses you for more conclusions about the lab results",
action="Assertively tell them that you cannot help and they should call the office",
)
return journey
async def main() -> None:
async with p.Server() as server:
agent = await server.create_agent(
name="Healthcare Agent",
description="Is empathetic and calming to the patient.",
)
await add_domain_glossary(agent)
scheduling_journey = await create_scheduling_journey(server, agent)
lab_results_journey = await create_lab_results_journey(server, agent)
status_inquiry = await agent.create_observation(
"The patient asks to follow up on their visit, but it's not clear in which way",
)
# Use this observation to disambiguate between the two journeys
await status_inquiry.disambiguate([scheduling_journey, lab_results_journey])
await agent.create_guideline(
condition="The patient asks about insurance",
action="List the insurance providers we accept, and tell them to call the office for more details",
tools=[get_insurance_providers],
)
await agent.create_guideline(
condition="The patient asks to talk to a human agent",
action="Ask them to call the office, providing the phone number",
)
await agent.create_guideline(
condition="The patient inquires about something that has nothing to do with our healthcare",
action="Kindly tell them you cannot assist with off-topic inquiries - do not engage with their request.",
)
if __name__ == "__main__":
asyncio.run(main())
+258
View File
@@ -0,0 +1,258 @@
# travel_voice_agent.py
import parlant.sdk as p
import asyncio
from datetime import datetime
@p.tool
async def get_available_destinations(context: p.ToolContext) -> p.ToolResult:
return p.ToolResult(
[
"Paris, France",
"Tokyo, Japan",
"Bali, Indonesia",
"New York, USA",
]
)
@p.tool
async def get_available_flights(context: p.ToolContext, destination: str) -> p.ToolResult:
# Simulate fetching available flights from a booking system
return p.ToolResult(
data=[
"Flight 123 - June 15, 9:00 AM, $850",
"Flight 321 - June 16, 2:30 PM, $720",
"Flight 987 - June 17, 6:45 PM, $680",
]
)
@p.tool
async def get_alternative_flights(context: p.ToolContext, destination: str) -> p.ToolResult:
# Simulate fetching alternative flights with different dates
return p.ToolResult(
data=[
"Flight 485 - June 25, 11:00 AM, $920",
"Flight 516 - July 2, 4:15 PM, $780",
]
)
@p.tool
async def book_flight(context: p.ToolContext, flight_details: str) -> p.ToolResult:
# Simulate booking the flight
return p.ToolResult(
data=f"Flight booked: {flight_details} for {p.Customer.current.name}. "
f"Confirmation number: TRV-{datetime.now().strftime('%Y%m%d')}-001"
)
@p.tool
async def get_booking_status(context: p.ToolContext, confirmation_number: str) -> p.ToolResult:
# Simulate fetching booking status from a reservation system,
# using the customer ID from the context.
booking_info = {
"status": "Confirmed",
"details": "Flight to Paris on June 15, 9:00 AM. Seat 12A assigned.",
"notes": "Check-in opens 24 hours before departure.",
}
return p.ToolResult(
data={
"status": booking_info["status"],
"details": booking_info["details"],
"notes": booking_info["notes"],
}
)
async def add_domain_glossary(agent: p.Agent) -> None:
await agent.create_term(
name="Office Phone Number",
description="The phone number of our travel agency office, at +1-800-TRAVEL-1",
synonyms=["contact number", "customer service number", "support line"],
)
await agent.create_term(
name="Baggage Policy",
description="This describes the rules and fees associated with checked and carry-on baggage.",
synonyms=["luggage policy", "baggage rules", "carry-on policy"],
)
await agent.create_term(
name="Cancellation Policy",
description="This outlines the terms and conditions for cancelling a booking, including any fees or deadlines.",
synonyms=["refund policy", "cancellation terms"],
)
await agent.create_term(
name="Travel Insurance",
description="An optional service that provides coverage for trip cancellations, medical emergencies, lost luggage, and other travel-related issues.",
synonyms=["insurance", "trip protection", "travel protection"],
)
# Add other specific terms and definitions here, as needed...
async def create_flight_booking_journey(server: p.Server, agent: p.Agent) -> p.Journey:
# Create the journey
journey = await agent.create_journey(
title="Book a Flight",
description="Helps the customer find and book a flight to their desired destination.",
triggers=["The customer wants to book a flight"],
)
# First, determine the destination
t0 = await journey.initial_state.transition_to(chat_state="Ask about the destination")
# Then ask about preferred travel dates
t1 = await t0.target.transition_to(chat_state="Ask about preferred travel dates")
# Load available flights into context
t2 = await t1.target.transition_to(tool_state=get_available_flights)
# Present flight options
# We will transition conditionally from here based on the customer's response
t3 = await t2.target.transition_to(
chat_state="Present available flights and ask which one works for them"
)
# We'll start with the happy path where the customer picks a flight
t4 = await t3.target.transition_to(
chat_state="Collect passenger information and confirm booking details before proceeding",
condition="The customer selects a flight",
)
t5 = await t4.target.transition_to(
tool_state=book_flight,
condition="The customer confirms the booking details",
)
t6 = await t5.target.transition_to(chat_state="Provide confirmation number and booking summary")
await t6.target.transition_to(state=p.END_JOURNEY)
# Otherwise, if none of the flights work, offer alternative dates
t7 = await t3.target.transition_to(
tool_state=get_alternative_flights,
condition="None of the flights work for the customer",
)
t8 = await t7.target.transition_to(chat_state="Present alternative flights and ask if any work")
# Transition back to our happy-path if they pick a flight
await t8.target.transition_to(state=t4.target, condition="The customer selects a flight")
# Otherwise, ask them to call the office or check our website
t9 = await t8.target.transition_to(
chat_state="Suggest calling our office or visiting our website for more options",
condition="None of the alternative flights work either",
)
await t9.target.transition_to(state=p.END_JOURNEY)
# Handle edge-cases deliberately with guidelines
await journey.create_guideline(
condition="The customer mentions they need to travel urgently or it's an emergency",
action="Direct them to call our office immediately for priority booking assistance",
)
await journey.create_guideline(
condition="The customer asks about visa requirements",
action="Inform them that visa requirements vary by destination and nationality, and suggest they check with the embassy or consulate",
)
return journey
async def create_booking_status_journey(server: p.Server, agent: p.Agent) -> p.Journey:
# Create the journey
journey = await agent.create_journey(
title="Check Booking Status",
description="Retrieves the customer's booking status and provides relevant information.",
triggers=["The customer wants to check their booking status"],
)
t0 = await journey.initial_state.transition_to(
chat_state="Ask for the confirmation number or booking reference"
)
t1 = await t0.target.transition_to(tool_state=get_booking_status)
await t1.target.transition_to(
chat_state="Tell the customer that the booking could not be found and ask them to verify the confirmation number or call the office",
condition="The booking could not be found",
)
await t1.target.transition_to(
chat_state="Provide the booking details and confirm everything is in order",
condition="The booking is confirmed and all details are correct",
)
await t1.target.transition_to(
chat_state="Present the booking information and mention any issues or pending actions required",
condition="The booking has issues or requires customer action",
)
# Handle edge cases with guidelines...
await journey.create_guideline(
condition="The customer wants to make changes to their booking",
action="Explain the change policy and direct them to call our office for assistance with modifications",
)
await journey.create_guideline(
condition="The customer is concerned about potential cancellation",
action="Provide our cancellation policy and suggest they call the office to discuss their options",
)
return journey
async def configure_container(container: p.Container) -> p.Container:
container[p.PerceivedPerformancePolicy] = p.VoiceOptimizedPerceivedPerformancePolicy()
return container
async def main() -> None:
async with p.Server(
configure_container=configure_container,
) as server:
agent = await server.create_agent(
name="Walker",
description="Is a knowledgeable travel agent who helps book flights, answer travel questions, and manage reservations.",
output_mode=p.OutputMode.STREAM,
)
await add_domain_glossary(agent)
await create_flight_booking_journey(server, agent)
await create_booking_status_journey(server, agent)
await agent.create_guideline(
condition="The customer asks about travel insurance",
action="Explain our travel insurance options, coverage details, and pricing, then offer to add it to their booking",
)
await agent.create_guideline(
condition="The customer asks about hotel or car rental options",
action="Inform them that we can help with complete travel packages and suggest they call our office or visit our website for hotel and car rental bookings",
)
await agent.create_guideline(
condition="The customer asks to speak with a human agent",
action="Provide the office phone number and office hours, and offer to help them with anything else in the meantime",
)
await agent.create_guideline(
condition="The customer asks about destinations or activities unrelated to booking travel",
action="Acknowledge their interest but explain that you specialize in travel bookings, and gently redirect to how you can help with their travel plans",
)
await agent.create_guideline(
condition="The customer inquires about something that has nothing to do with travel",
action="Kindly tell them you cannot assist with off-topic inquiries - do not engage with their request.",
)
if __name__ == "__main__":
asyncio.run(main())
+459
View File
@@ -0,0 +1,459 @@
# Parlant
> Open-source AI agent framework for building customer-facing conversational agents with ensured rule compliance and enterprise-grade behavior control.
Parlant is a Python framework for building **predictable, business-aligned AI agents**. Unlike prompt-based approaches, Parlant ensures agents follow behavioral rules through structured guideline matching and contextual application.
Install: `pip install parlant`
## Quick Start Example
```python
import parlant.sdk as p
import asyncio
@p.tool
async def get_account_balance(context: p.ToolContext, account_id: str) -> p.ToolResult:
# Your business logic here
balance = 1234.56
return p.ToolResult(data={"balance": balance, "currency": "USD"})
async def main() -> None:
async with p.Server() as server:
agent = await server.create_agent(
name="Banking Assistant",
description="Helpful and professional banking support agent",
)
# Add behavioral guidelines
await agent.create_guideline(
condition="The customer asks about their balance",
action="Retrieve and clearly present their account balance",
tools=[get_account_balance],
)
await agent.create_guideline(
condition="The customer asks about topics unrelated to banking",
action="Politely decline and redirect to banking topics",
)
# Server runs until shutdown - no additional code needed here.
# When the process exits, the context manager handles cleanup automatically.
if __name__ == "__main__":
asyncio.run(main())
```
Run with: `python your_agent.py` then open http://localhost:8800
---
## Core Concepts
### 1. Agents
AI personalities that interact with customers. Created via `server.create_agent()`.
Learn more: [Agents Documentation](https://parlant.io/docs/concepts/agents)
### 2. Guidelines
Natural language if-then rules that control agent behavior contextually:
```python
await agent.create_guideline(
condition="When this situation occurs", # The trigger
action="Do this specific thing", # The response behavior
tools=[optional_tool], # Tools available for this guideline
)
```
Learn more: [Guidelines Documentation](https://parlant.io/docs/concepts/customization/guidelines)
### 3. Journeys
Structured multi-step interaction flows (state machines):
```python
journey = await agent.create_journey(
title="Order Support",
description="Helps customers with order issues",
conditions=["The customer has an order-related question"],
)
# Chain states with transitions
t0 = await journey.initial_state.transition_to(chat_state="Ask for order number")
t1 = await t0.target.transition_to(tool_state=lookup_order)
t2 = await t1.target.transition_to(
chat_state="Present order status",
condition="Order was found",
)
await t2.target.transition_to(state=p.END_JOURNEY)
```
Learn more: [Journeys Documentation](https://parlant.io/docs/concepts/customization/journeys)
### 4. Tools
Functions the agent can call. Always async, always return `ToolResult`:
```python
@p.tool
async def my_tool(
context: p.ToolContext, # Always first param
required_param: str, # Required parameters
optional_param: int = 10, # Optional with defaults
) -> p.ToolResult:
# Business logic here
return p.ToolResult(data={"key": "value"})
```
Learn more: [Tools Documentation](https://parlant.io/docs/concepts/customization/tools)
### 5. Glossary Terms
Teach agents domain-specific terminology:
```python
await agent.create_term(
name="SKU",
description="Stock Keeping Unit - unique product identifier",
synonyms=["product code", "item number"],
)
```
Learn more: [Glossary Documentation](https://parlant.io/docs/concepts/customization/glossary)
### 6. Canned Responses
Template responses to eliminate hallucination and control language style:
```python
await agent.add_canned_response(
key="greeting",
content="Hello! I'm here to help with your order. How can I assist you today?",
)
```
Learn more: [Canned Responses Documentation](https://parlant.io/docs/concepts/customization/canned-responses)
### 7. Streaming Mode
Agents can deliver responses in real-time chunks for a more interactive experience:
```python
from parlant.sdk import MessageOutputMode
agent = await server.create_agent(
name="Support Agent",
description="Helpful support agent",
message_output_mode=MessageOutputMode.STREAMING, # Enable streaming
)
```
Output modes:
- `MessageOutputMode.BLOCK` (default): Complete response delivered at once
- `MessageOutputMode.STREAMING`: Response delivered in real-time chunks with token-by-token animation
Streaming mode provides actual token usage information (input/output tokens) in generation metadata.
---
## Common Patterns
### Pattern: Tool with Customer Context
```python
@p.tool
async def get_customer_orders(context: p.ToolContext) -> p.ToolResult:
# context.customer_id is automatically available
orders = await db.get_orders(context.customer_id)
return p.ToolResult(data=orders)
```
### Pattern: Conditional Transitions
```python
# Branch based on conditions
t0 = await journey.initial_state.transition_to(tool_state=check_eligibility)
# Multiple outgoing transitions from same state
await t0.target.transition_to(
chat_state="Approve the request",
condition="Customer is eligible",
)
await t0.target.transition_to(
chat_state="Explain why they're not eligible",
condition="Customer is not eligible",
)
```
### Pattern: Disambiguation
Handle ambiguous user intents:
```python
observation = await agent.create_observation(
"The customer mentions a problem but doesn't specify what kind",
)
await observation.disambiguate([billing_journey, technical_support_journey])
```
### Pattern: Journey-Scoped Guidelines
Guidelines that only apply within a specific journey:
```python
await journey.create_guideline(
condition="Customer seems frustrated",
action="Acknowledge their frustration and offer to escalate",
)
```
---
## Environment Variables
Set your LLM provider credentials before running. Examples:
- `OPENAI_API_KEY` - For OpenAI
- `ANTHROPIC_API_KEY` - For Anthropic
- `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT` - For Azure OpenAI
Learn more: [Installation & Setup](https://parlant.io/docs/quickstart/installation)
---
## Full Example: Customer Service Agent
```python
import parlant.sdk as p
import asyncio
# Define tools
@p.tool
async def lookup_order(context: p.ToolContext, order_id: str) -> p.ToolResult:
# Simulated order lookup
return p.ToolResult(data={
"order_id": order_id,
"status": "shipped",
"tracking": "1Z999AA10123456784",
})
@p.tool
async def request_refund(context: p.ToolContext, order_id: str, reason: str) -> p.ToolResult:
return p.ToolResult(data={"refund_id": "REF-12345", "status": "processing"})
async def create_order_journey(agent: p.Agent) -> p.Journey:
journey = await agent.create_journey(
title="Order Support",
description="Helps customers check order status or request refunds",
conditions=["Customer asks about an order"],
)
# Step 1: Get order number
t0 = await journey.initial_state.transition_to(
chat_state="Ask the customer for their order number"
)
# Step 2: Look up the order
t1 = await t0.target.transition_to(tool_state=lookup_order)
# Step 3a: Order found - show status
t2 = await t1.target.transition_to(
chat_state="Present the order status and tracking information",
condition="Order was found",
)
# Step 3b: Order not found
await t1.target.transition_to(
chat_state="Apologize and ask them to verify the order number",
condition="Order was not found",
)
# Step 4: Check if they need anything else
t3 = await t2.target.transition_to(
chat_state="Ask if they need help with anything else regarding this order"
)
# Step 5a: They want a refund
t4 = await t3.target.transition_to(
tool_state=request_refund,
condition="Customer requests a refund",
)
await t4.target.transition_to(
chat_state="Confirm the refund has been initiated"
)
# Step 5b: They're satisfied
await t3.target.transition_to(
state=p.END_JOURNEY,
condition="Customer has no more questions",
)
# Journey-specific guidelines
await journey.create_guideline(
condition="Customer is upset about a delayed order",
action="Apologize sincerely and offer expedited shipping on their next order",
)
return journey
async def main() -> None:
async with p.Server() as server:
agent = await server.create_agent(
name="Support Agent",
description="Friendly and efficient customer support representative",
)
# Domain knowledge
await agent.create_term(
name="Express Shipping",
description="2-day delivery, costs $9.99",
)
# Create journey
await create_order_journey(agent)
# Global guidelines (apply everywhere)
await agent.create_guideline(
condition="Customer uses profanity or is abusive",
action="Calmly ask them to be respectful, or offer to end the conversation",
)
await agent.create_guideline(
condition="Customer asks to speak to a human",
action="Provide the support phone number: 1-800-555-0123",
)
# Server runs until shutdown - no additional code needed here.
# When the process exits, the context manager handles cleanup automatically.
if __name__ == "__main__":
asyncio.run(main())
```
---
## Testing Framework
Parlant includes a testing framework for validating agent behavior using NLP-based assertions.
### Basic Test Structure
```python
from parlant.testing import Suite
from parlant.testing.steps import AgentMessage, CustomerMessage
suite = Suite(
server_url="http://localhost:8800",
agent_id="your_agent_id",
)
@suite.scenario
async def test_greeting() -> None:
async with suite.session() as session:
response = await session.send("Hello!")
await response.should("be a friendly greeting or offer to help")
@suite.scenario
async def test_appointment_inquiry() -> None:
async with suite.session() as session:
response = await session.send("Can I schedule an appointment?")
await response.should("acknowledge the request or ask for more details")
```
Run tests with: `parlant-test your_test_file.py`
### NLP-Based Assertions
The `response.should()` method uses NLP to evaluate conditions against the full conversation context:
```python
# Single condition
await response.should("be polite and professional")
# Multiple conditions (evaluated in parallel)
await response.should([
"ask for the reason for the visit",
"be polite",
"not mention pricing",
])
```
### Multi-Turn Conversations with unfold()
Test multi-turn conversations where each step builds on history:
```python
@suite.scenario
async def test_booking_flow() -> None:
async with suite.session() as session:
await session.unfold([
# History-only steps (no assertion)
CustomerMessage("Hello"),
AgentMessage("Hi! How can I help you today?"),
# Steps with assertions create sub-tests
CustomerMessage("I need to book an appointment"),
AgentMessage(
text="What's the reason for your visit?",
should=["ask for the reason", "be polite"],
),
CustomerMessage("Regular checkup"),
AgentMessage(
text="I have Monday at 10am or Wednesday at 2pm available.",
should="offer appointment times",
),
])
```
**How unfold() works:**
- `CustomerMessage(text)` - Customer's message in the conversation
- `AgentMessage(text, should)` - Expected agent response
- `text`: Reference response used as history for subsequent tests
- `should`: Assertion condition(s). Only steps with `should` create sub-tests
- Each sub-test gets a fresh session with prefab history of all prior steps
- Sub-tests run sequentially and report results independently
### Repeated Scenarios
Run the same scenario multiple times for consistency testing:
```python
@suite.scenario(repetitions=3)
async def test_consistent_greeting() -> None:
async with suite.session() as session:
response = await session.send("Hello")
await response.should("greet the customer")
```
### Hooks
```python
@suite.before_all
async def setup() -> None:
# Runs once before all tests
suite.context["api_key"] = "test-key"
@suite.after_all
async def teardown() -> None:
# Runs once after all tests
pass
@suite.before_each
async def before_test(test_name: str) -> None:
# Runs before each test
pass
@suite.after_each
async def after_test(test_name: str, passed: bool, error: str | None) -> None:
# Runs after each test
pass
```
### CLI Options
```bash
# Run all tests
parlant-test tests.py
# Filter by pattern
parlant-test tests.py -k "greeting"
# Run tests in parallel
parlant-test tests.py --parallel
# Custom timeout (seconds)
parlant-test tests.py --timeout 120
```
---
## Links
- Documentation: https://parlant.io/
- GitHub: https://github.com/emcie-co/parlant
- PyPI: https://pypi.org/project/parlant/
- Discord: https://discord.gg/duxWqxKk6J
+10
View File
@@ -0,0 +1,10 @@
[mypy]
strict = True
namespace_packages = True
explicit_package_bases = True
warn_unused_ignores = False
mypy_path = src
files = src, tests
disable_error_code = type-abstract
exclude = scripts
plugins = pydantic.mypy
Generated
+8784
View File
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
[project]
name = "parlant"
version = "3.3.1"
description = ""
readme = "README.md"
license = "Apache-2.0"
authors = [
{name = "Yam Marcovitz", email = "yam@emcie.co"},
{name = "Dor Zohar", email = "dor@emcie.co"},
]
requires-python = ">=3.10,<3.15" # Restricted for torch 2.8+ compatibility with triton
dependencies = [
"aiofiles>=24.1.0",
"aiopenapi3>=0.8.1,<0.9.0", # 0.9.0 hard-pins pydantic==2.11.9, which conflicts with downstream pydantic>=2.12 consumers
"aiorwlock>=1.5.0",
"authlib>=1.6.11",
"boto3>=1.35.70",
"cachetools>=6.0.0",
"click>=8.1.7",
"colorama>=0.4.6",
"coloredlogs>=15.0.1",
"contextvars>=2.4",
"croniter>=5.0.1",
"fastapi>=0.120.0",
"fastmcp>=3.2.0",
"griffe<2", # Pinned to <2 because fastmcp (at least v3.2.4) is not compatible with griffe 2.0+ due to breaking changes. Can be removed in case of future fastmcp updates.
"httpx>=0.28.1",
"jinja2>=3.1.6",
"jsonfinder>=0.4.2",
"jsonschema>=4.23.0",
"lagom>=2.6.0",
"limits>=5.5.0",
"mcp>=1.23.0",
"more-itertools>=10.3.0",
"nano-vectordb>=0.0.4.3",
"nanoid>=2.0.0",
"networkx[default]>=3.3",
"openai>=2.8.0",
"openapi3-parser==1.1.21",
"opentelemetry-api>=1.37.0",
"opentelemetry-exporter-otlp>=1.37.0",
"opentelemetry-instrumentation>=0.58b0",
"opentelemetry-sdk>=1.37.0",
"parlant-client @ git+https://github.com/emcie-co/parlant-client-python.git@v3.2.0",
"python-dateutil>=2.8.2",
"python-dotenv>=1.0.1",
"requests>=2.33.0",
"rich>=14.0.0",
"semver>=3.0.2",
"starlette>=0.49.0", # Specified for fix vulnerability of lower versions. Can be removed in case of future conflicts.
"structlog>=24.4.0",
"tabulate>=0.9.0",
"tiktoken>=0.12",
"tokenizers>=0.21",
"toml>=0.10.2",
"types-aiofiles>=24.1.0.20240626",
"types-cachetools>=6.0.0.20250525",
"types-croniter>=4.0.0.20241030",
"types-jsonschema>=4.22.0.20240610",
"uvicorn>=0.38.0",
"websocket-client>=1.5.3",
"wsproto>=1.2.0",
"xxhash>=3.5.0",
]
[project.scripts]
parlant = "parlant.bin.client:main"
parlant-server = "parlant.bin.server:main"
parlant-prepare-migration = "parlant.bin.prepare_migration:main"
[project.optional-dependencies]
chroma = ["chromadb>=1.1.1"]
qdrant = ["qdrant-client>=1.7.0"]
mongo = ["pymongo>=4.11.1"]
anthropic = ["anthropic>=0.60.0", "torch>=2.8.0", "transformers>=4.53.0"]
aws = ["anthropic>=0.60.0", "transformers>=4.53.0", "torch>=2.8.0"]
together = ["torch>=2.8.0", "together>=1.5.26", "transformers>=4.53.0"]
cerebras = ["cerebras-cloud-sdk>=1.25.0", "torch>=2.8.0", "transformers>=4.53.0"]
deepseek = ["torch>=2.8.0", "transformers>=4.53.0"]
gemini = ["google-genai>=1.36.0", "google-api-core>=2.24.2", "torch>=2.8.0"]
vertex = [
"google-genai>=1.36.0",
"google-api-core>=2.24.2",
"google-auth>=2.40.0",
"torch>=2.8.0",
"anthropic>=0.60.0",
"transformers>=4.53.0",
]
ollama = ["ollama>=0.5.0"]
litellm = ["litellm>=1.83.10", "torch>=2.8.0", "transformers>=4.53.0"]
azure = ["azure-identity>=1.20.0"]
# fireworks = ["fireworks-ai>=0.19.19"] # Disabled: pins protobuf=5.29.3 which has CVE-2025-4565
mistral = ["mistralai>=1.0.0"]
snowflake = ["snowflake-connector-python>=3.12.0"]
zhipu = ["zhipuai>=2.0.0"]
[dependency-groups]
dev = [
"ipython>=8.26.0",
"mypy>=1.18.1",
"pep8-naming>=0.13.3",
"pytest>=9.0.3",
"pytest-asyncio>=0.23.5",
"pytest-bdd>=8.1.0",
"pytest-cov>=5.0.0",
"pytest-tap>=3.4",
"pytest-timing @ git+https://github.com/emcie-co/mc-spitfyre.git@timing_v0.1.4#subdirectory=pytest-timing",
"python-dotenv>=1.0.1",
"ruff>=0.9.1",
"types-python-dateutil>=2.8.19.20240106",
"types-requests>=2.32.0.20240712",
"types-toml>=0.10.8",
"pytest-xdist>=3.6.1",
]
[tool.uv]
override-dependencies = [
"pyjwt>=2.11.1", # Override zhipuai's pyjwt<2.9.0 cap to allow mcp>=1.23.0 (CVE-2025-66416, CVE-2026-32597)
"onnxruntime<1.24; python_full_version < '3.11'", # 1.24+ dropped Python 3.10 support
]
constraint-dependencies = [
# Minimum safe versions for transitive dependencies with known CVEs.
# These constraints only affect resolution — they do not force installation.
"aiohttp>=3.13.4",
"azure-core>=1.38.0",
"cryptography>=46.0.7",
"diskcache>=5.6.4",
"filelock>=3.20.3",
"fonttools>=4.60.2",
"Mako>=1.3.12",
"orjson>=3.11.6",
"pillow>=12.2.0",
"protobuf>=6.33.5",
"pyasn1>=0.6.3",
"Pygments>=2.20.0",
"pyopenssl>=26.0.0",
"python-multipart>=0.0.27",
"urllib3>=2.7.0",
"werkzeug>=3.1.6",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["src/parlant"]
+10
View File
@@ -0,0 +1,10 @@
[pytest]
asyncio_mode = auto
bdd_features_base_dir = tests/
filterwarnings =
ignore::pytest.PytestDeprecationWarning:pytest_bdd.*
ignore::pytest.PytestWarning:.*usefixtures.*has no effect
addopts = "--import-mode=importlib"
markers =
engine: marks tests related to engine behavior
evaluation: marks tests related to preprocessing evaluations
+118
View File
@@ -0,0 +1,118 @@
{
"test_plan_list": [
{
"plan": "complete",
"_comment": "utility plan equivalent to running all the other plans. used for vscode test explorer.",
"policy_tests": [
{
"policy": "default",
"tests": [
"tests/(?!core)"
]
},
{
"policy": "strict3",
"tests": [
"tests/core/stable"
]
},
{
"policy": "majority3",
"tests": [
"tests/core/unstable"
]
}
]
},
{
"plan": "default_disabled",
"_comment": "baseline plan to disbale all tests that are not specifically enabled by using it as a base for other plans",
"policy_tests": [
{
"policy": "disable",
"tests": [
"tests/"
]
}
]
},
{
"plan": "deterministic",
"_comment": "Plan to test all the non-stochastic tests. All should pass.",
"policy_tests": [
{
"policy": "default",
"tests": [
"tests/(?!core)"
]
}
]
},
{
"plan": "core_stable",
"_comment": "Stable stochastic tests should pass 3 out of 3 runs.",
"policy_tests": [
{
"policy": "strict3",
"tests": [
"tests/core/stable"
]
}
]
},
{
"plan": "core_unstable",
"_comment": "Unstable test are allowed one failure out of 3 runs. (Temporarily encompases all of `core`)",
"policy_tests": [
{
"policy": "majority3",
"tests": [
"tests/core/unstable"
]
}
]
}
],
"policy_list": [
{
"policy": "disable",
"at_least": 0,
"out_of": 0
},
{
"policy": "default",
"at_least": 1,
"out_of": 1
},
{
"policy": "experimental",
"at_least": 0,
"out_of": 3,
"pass_fast": false
},
{
"policy": "majority3",
"at_least": 2,
"out_of": 3
},
{
"policy": "strict3",
"at_least": 3,
"out_of": 3
}
],
"plan_fallback_list": [
{
"plan": "deterministic",
"overrides": "default_disabled"
},
{
"plan": "core_stable",
"overrides": "default_disabled"
},
{
"plan": "core_unstable",
"overrides": "default_disabled"
}
]
}
+75
View File
@@ -0,0 +1,75 @@
# Exclude a variety of commonly ignored directories.
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".ipynb_checkpoints",
".mypy_cache",
".nox",
".pants.d",
".pyenv",
".pytest_cache",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
".vscode",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"site-packages",
"venv",
]
# Same as Black.
line-length = 100
indent-width = 4
# Assume Python 3.8
target-version = "py312"
[lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
select = [
"E4",
"E7",
"E9",
"F",
"W293",
"PLR1722",
"PTH204",
"PLW2901",
#"ASYNC230", TODO: don't use sync open() in async methods
#"PTH123", TODO: prefer Path.open() over open()
#"UP035", TODO: preferred import sources
#"UP017", TODO: prefer datetimc.UTC
#"I001", TODO: sort imports
]
ignore = ["E203"]
# Allow fix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[format]
# Like Black, use double quotes for strings.
quote-style = "double"
# Like Black, indent with spaces, rather than tabs.
indent-style = "space"
# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false
# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"
@@ -0,0 +1,54 @@
#!/bin/sh
# Print initial disk space usage
df -h / | awk 'NR==2 {printf "Before cleanup: %s used, %s free\n", $3, $4}'
# Remove docker images
sudo docker rmi $(docker image ls -aq) >/dev/null 2>&1 || true
# Remove development toolchains and SDK directories
sudo rm -rf \
/opt/hostedtoolcache/* \
/usr/local/lib/android \
/usr/share/dotnet \
/usr/local/share/powershell \
/usr/share/swift \
/opt/ghc \
/usr/local/.ghcup \
/usr/lib/jvm \
/usr/local/julia* \
/usr/local/n \
/usr/local/share/chromium \
/usr/local/share/vcpkg \
>/dev/null 2>&1 || true
# Remove unnecessary packages
sudo apt-get remove -y \
azure-cli \
google-cloud-sdk \
firefox \
google-chrome-stable \
microsoft-edge-stable \
mysql* \
mongodb-org* \
dotnet* \
php* \
>/dev/null 2>&1 || true
# Clean up package system
sudo apt-get autoremove -y >/dev/null 2>&1
sudo apt-get clean -y >/dev/null 2>&1
# Clean up package caches and data
sudo rm -rf \
/var/lib/docker/* \
/var/lib/gems/* \
/var/lib/apt/lists/* \
/var/cache/* \
/var/lib/snapd \
>/dev/null 2>&1 || true
# Print final disk space usage and difference
df -h / | awk -v before="$(df -h / | awk 'NR==2 {print $3}')" \
'NR==2 {printf "After cleanup: %s used, %s free (freed %s)\n",
$3, $4, substr(before,1,length(before)-1) - substr($3,1,length($3)-1) "G"}'
+8
View File
@@ -0,0 +1,8 @@
instances:
- url: https://docs.parlant.io
title: Parlant | Documentation
navigation:
- api: API Reference
colors:
accentPrimary: '#ffffff'
background: '#000000'
+4
View File
@@ -0,0 +1,4 @@
{
"organization": "parlant",
"version": "0.61.22"
}
+21
View File
@@ -0,0 +1,21 @@
api:
specs:
- openapi: openapi/parlant.openapi.json
default-group: local
groups:
local:
generators:
- name: fernapi/fern-typescript-node-sdk
version: 0.49.2
config:
namespaceExport: Parlant
output:
location: local-file-system
path: ../sdks/typescript
- name: fernapi/fern-python-sdk
version: 4.3.3
config:
client_class_name: ParlantClient
output:
location: local-file-system
path: ../sdks/python
+127
View File
@@ -0,0 +1,127 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#!python
import os
from pathlib import Path
import re
import subprocess
import shutil
import sys
import time
DIR_SCRIPT_ROOT = Path(__file__).parent
DIR_FERN = DIR_SCRIPT_ROOT / "fern"
DIR_SDKS = DIR_SCRIPT_ROOT / "sdks"
DIR_PROJECTS_WORKSPACE = DIR_SCRIPT_ROOT / ".." / ".." / "parlant-sdks"
PATHDICT_SDK_REPO_TARGETS = {
"python": DIR_PROJECTS_WORKSPACE / "parlant-client-python" / "src" / "parlant" / "client",
"typescript": DIR_PROJECTS_WORKSPACE / "parlant-client-typescript" / "src",
}
def replace_in_files(rootdir: Path, search: str, replace: str) -> None:
rewrites: dict[str, str] = {}
for subdir, _dirs, files in os.walk(rootdir):
for file in files:
file_path = os.path.join(subdir, file)
with open(file_path, "r") as current_file:
current_file_content = current_file.read()
if "from parlant import" not in current_file_content:
continue
current_file_content = re.sub(search, replace, current_file_content)
rewrites[file_path] = current_file_content
for path, content in rewrites.items():
with open(path, "w") as current_file:
current_file.write(content)
if __name__ == "__main__":
DEFAULT_PORT = 8800
port = DEFAULT_PORT
if len(sys.argv) >= 2:
port = int(sys.argv[1])
print(f"The script will now try to fetch the latest openapi.json from http://localhost:{port}.")
input(
f"Ensure that parlant-server is running on port {port} and then press any key to continue..."
)
output_openapi_json = DIR_FERN / "openapi/parlant.openapi.json"
output_openapi_json.parent.mkdir(exist_ok=True)
output_openapi_json.touch()
status, output = subprocess.getstatusoutput(
f"curl -m 3 -o {output_openapi_json} http://localhost:{port}/openapi.json"
)
if status != 0:
print(f"Failed to fetch openapi.json from http://localhost:{port}", file=sys.stderr)
print("Please ensure that the desired Parlant server is accessible there.", file=sys.stderr)
sys.exit(1)
for sdk, repo in PATHDICT_SDK_REPO_TARGETS.items():
if os.path.isdir(repo):
continue
raise Exception(f"Missing dir for {sdk}: {repo}")
print(f"Fetched openapi.json from http://localhost:{port}.")
if not DIR_FERN.is_dir():
raise Exception("fern directory not found where expected")
for sdk in PATHDICT_SDK_REPO_TARGETS:
sdk_path = DIR_SDKS / sdk
if not sdk_path.is_dir():
continue
print(f"Deleting old {sdk} sdk")
print(f"> rm -rf {sdk_path}")
shutil.rmtree(sdk_path)
os.chdir(DIR_SCRIPT_ROOT)
print("Invoking fern generation")
print("> fern generate --log-level=debug")
exit_code, generate_output = subprocess.getstatusoutput("fern generate --log-level=debug")
with open("fern.generate.log", "w") as fern_log:
fern_log.write(generate_output)
if exit_code != os.EX_OK:
raise Exception(generate_output)
print("Renaming `parlant` to `parlant.client` in python imports")
replace_in_files(DIR_SDKS / "python", "from parlant import", "from parlant.client import")
print("touching python typing")
print(f"> touch {DIR_SDKS}/python/py.typed")
open(DIR_SDKS / "python/py.typed", "w")
for sdk, repo in PATHDICT_SDK_REPO_TARGETS.items():
print(f"!DANGER! Deleting local `{repo}` directory and all of its contents!")
time.sleep(3)
print(f"> rm -rf {repo}")
shutil.rmtree(repo)
for sdk, repo in PATHDICT_SDK_REPO_TARGETS.items():
print(f"copying newly generated {sdk} files to {repo}")
print(f"> cp -rp {DIR_SDKS}/{sdk} {repo}")
shutil.copytree(DIR_SDKS / sdk, repo)
+31
View File
@@ -0,0 +1,31 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import subprocess
from pathlib import Path
SCRIPTS_DIR = Path("./scripts")
def install_packages() -> None:
subprocess.run(["python", SCRIPTS_DIR / "install_packages.py"])
def install_hooks() -> None:
subprocess.run(["git", "config", "core.hooksPath", ".githooks"], check=True)
if __name__ == "__main__":
install_packages()
install_hooks()
+36
View File
@@ -0,0 +1,36 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import subprocess
import sys
from utils import Package, die, for_each_package
def install_package(package: Package) -> None:
if not package.uses_uv:
print(f"Skipping {package.path}...")
return
print(f"Installing {package.path}...")
status, output = subprocess.getstatusoutput(f"uv sync --all-extras --directory {package.path}")
if status != 0:
print(output, file=sys.stderr)
die(f"error: failed to install package: {package.path}")
if __name__ == "__main__":
for_each_package(install_package)
+47
View File
@@ -0,0 +1,47 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import sys
from functools import partial
from utils import Package, die, for_each_package
def run_cmd_or_die(
cmd: str,
description: str,
package: Package,
) -> None:
print(f"Running {cmd} on {package.name}...")
status, output = package.run_cmd(cmd)
if status != 0:
print(output, file=sys.stderr)
die(f"error: package '{package.path}': {description}")
def lint_package(mypy: bool, ruff: bool, package: Package) -> None:
if mypy:
run_cmd_or_die("mypy", "Please fix MyPy lint errors", package)
if ruff:
run_cmd_or_die("ruff check", "Please fix Ruff lint errors", package)
run_cmd_or_die("ruff format --check", "Please format files with Ruff", package)
if __name__ == "__main__":
mypy = "--mypy" in sys.argv
ruff = "--ruff" in sys.argv
for_each_package(partial(lint_package, mypy, ruff))
+112
View File
@@ -0,0 +1,112 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#!/usr/bin/python3
import semver # type: ignore
import sys
import subprocess
import toml # type: ignore
from utils import die, for_each_package, Package, get_packages
def get_server_version() -> str:
server_package = next(p for p in get_packages() if p.name == "parlant")
project_file = server_package.path / "pyproject.toml"
pyproject = toml.load(project_file)
version = str(pyproject["tool"]["poetry"]["version"])
return version
def run_command(args: list[str]) -> None:
cmd = " ".join(args)
print(f"Running {cmd}")
build_process = subprocess.Popen(
args=args,
stdout=sys.stdout,
stderr=sys.stderr,
)
status = build_process.wait()
if status != 0:
die(f"error: command failed: {cmd}")
def publish_docker() -> None:
version = get_server_version()
version_info = semver.parse_version_info(version)
tag_versions = [
f"{version_info.major}.{version_info.minor}.{version_info.patch}.{version_info.prerelease}",
]
if not version_info.prerelease:
tag_versions = [
"latest",
f"{version_info.major}",
f"{version_info.major}.{version_info.minor}",
f"{version_info.major}.{version_info.minor}.{version_info.patch}",
]
else:
tag_versions = [
f"{version_info.major}.{version_info.minor}.{version_info.patch}.{version_info.prerelease}",
]
platforms = [
"linux/amd64",
"linux/arm64",
]
for version in tag_versions:
run_command(
[
"docker",
"buildx",
"build",
"--platform",
",".join(platforms),
"-t",
f"ghcr.io/emcie-co/parlant:{version}",
"-f",
"Dockerfile",
"--push",
".",
]
)
def publish_package(package: Package) -> None:
if not package.uses_uv or not package.publish:
print(f"Skipping {package.path}...")
return
status, output = package.run_cmd("uv build")
if status != 0:
print(output, file=sys.stderr)
die(f"error: package '{package.path}': build failed")
status, output = package.run_cmd("uv publish")
if status != 0:
print(output, file=sys.stderr)
die(f"error: package '{package.path}': publish failed")
if __name__ == "__main__":
for_each_package(publish_package)
publish_docker()
+80
View File
@@ -0,0 +1,80 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from dataclasses import dataclass
import os
from pathlib import Path
import subprocess
import sys
from typing import Callable, NoReturn
@dataclass(frozen=True)
class Package:
name: str
path: Path
uses_uv: bool
cmd_prefix: str
publish: bool
def run_cmd(self, cmd: str) -> tuple[int, str]:
print(f"Running command: {self.cmd_prefix} {cmd}")
return subprocess.getstatusoutput(f"{self.cmd_prefix} {cmd}")
def get_repo_root() -> Path:
status, output = subprocess.getstatusoutput("git rev-parse --show-toplevel")
if status != 0:
print(output, file=sys.stderr)
print("error: failed to get repo root", file=sys.stderr)
sys.exit(1)
return Path(output.strip())
def get_packages() -> list[Package]:
root = get_repo_root()
return [
Package(
name="parlant",
path=root / ".",
cmd_prefix="uv run",
uses_uv=True,
publish=True,
),
]
def for_each_package(
f: Callable[[Package], None],
enter_dir: bool = True,
) -> None:
for package in get_packages():
original_cwd = os.getcwd()
if enter_dir:
print(f"Entering {package.path}...")
os.chdir(package.path)
try:
f(package)
finally:
os.chdir(original_cwd)
def die(message: str) -> NoReturn:
print(message, file=sys.stderr)
sys.exit(1)
+172
View File
@@ -0,0 +1,172 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#!/usr/bin/python3
from functools import partial
from pathlib import Path
import semver # type: ignore
import subprocess
import sys
import re
import toml # type: ignore
from utils import die, for_each_package, Package, get_packages
def get_project_file(package: Package) -> Path:
return package.path / "pyproject.toml"
def get_current_version(package: Package) -> str:
content = toml.load(get_project_file(package))
return str(content["project"]["version"])
def set_package_version(version: str, package: Package) -> None:
if not package.uses_uv:
print(f"Skipping {package.path}...")
return
current_version = get_current_version(package)
print(f"Setting {package.name} from version {current_version} to version {version}")
project_file = get_project_file(package)
project_file_content = project_file.read_text()
with open(project_file, "w") as file:
project_file_content = re.sub(
f'\nversion = "{current_version}"\n',
f'\nversion = "{version}"\n',
project_file_content,
count=1,
)
project_file_content = re.sub(
f'\nparlant-(.+?) = "{current_version}"\n',
f'\nparlant-\\1 = "{version}"\n',
project_file_content,
)
file.write(project_file_content)
status, output = package.run_cmd("uv lock")
if status != 0:
print(output, file=sys.stderr)
die("error: failed to re-hash uv lock file")
def update_version_variable_in_code(version: str) -> None:
server_package = next(p for p in get_packages() if p.name == "parlant")
version_file: Path = server_package.path / "src/parlant/core/version.py"
version_file_content = version_file.read_text()
current_version = get_current_version(server_package)
version_file_content = re.sub(
f'VERSION = "{current_version}"',
f'VERSION = "{version}"',
version_file_content,
)
version_file.write_text(version_file_content)
def tag_repo(version: str) -> None:
status, output = subprocess.getstatusoutput(f'git tag "v{version}"')
if status != 0:
print(output, file=sys.stderr)
die(f"error: failed to tag repo: v{version}")
def get_current_server_version() -> str:
server_package = next(p for p in get_packages() if p.name == "parlant")
return get_current_version(server_package)
def update_version(
current_version: str,
major: bool,
minor: bool,
patch: bool,
rc: bool,
beta: bool,
alpha: bool,
) -> str:
assert sum((major, minor, patch)) <= 1, "Only one component can be bumped"
assert sum((rc, beta, alpha)) <= 1, "Only one pre-release label can be used"
version = semver.parse_version_info(current_version)
if major:
version = version.bump_major()
if minor:
version = version.bump_minor()
if patch:
version = version.bump_patch()
if rc:
version = version.bump_prerelease("rc")
elif beta:
version = version.bump_prerelease("beta")
elif alpha:
version = version.bump_prerelease("alpha")
else:
version = version.finalize_version()
return str(version)
def there_are_pending_git_changes() -> bool:
status, _ = subprocess.getstatusoutput(
"git diff --quiet && git diff --cached --quiet && git ls-files --others --exclude-standard"
)
return status != 0
def commit_version(version: str) -> bool:
status, _ = subprocess.getstatusoutput(f"git commit -am 'Release {version}' --no-verify")
return status != 0
if __name__ == "__main__":
if there_are_pending_git_changes():
die("error: version bumps must take place on a clean tree with no pending changes")
current_version = get_current_server_version()
major = "--major" in sys.argv
minor = "--minor" in sys.argv
patch = "--patch" in sys.argv
rc = "--rc" in sys.argv
beta = "--beta" in sys.argv
alpha = "--alpha" in sys.argv
new_version = update_version(current_version, major, minor, patch, rc, beta, alpha)
if current_version == new_version:
die("error: no component was selected to be bumped")
answer = input(f"Proceed with bumping {current_version} to {new_version} [N/y]?")
if answer not in "yY":
die("Canceled.")
update_version_variable_in_code(new_version)
for_each_package(partial(set_package_version, new_version))
commit_version(new_version)
tag_repo(new_version)
+457
View File
@@ -0,0 +1,457 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Awaitable, Callable, Mapping, Optional, Sequence, cast
from typing_extensions import override, Self
import aiofiles
from parlant.core.persistence.common import (
Cursor,
SortDirection,
Where,
matches_filters,
ensure_is_total,
ObjectId,
)
from parlant.core.async_utils import ReaderWriterLock
from parlant.core.persistence.document_database import (
CollectionIndex,
CollectionSort,
BaseDocument,
DeleteResult,
DocumentCollection,
DocumentDatabase,
FindResult,
InsertResult,
TDocument,
UpdateResult,
identity_loader,
)
from parlant.core.loggers import Logger
class JSONFileDocumentDatabase(DocumentDatabase):
def __init__(
self,
logger: Logger,
file_path: Path,
) -> None:
self.file_path = file_path
self._logger = logger
self._op_counter = 0
self._lock = ReaderWriterLock()
if not self.file_path.exists():
self.file_path.write_text(json.dumps({}))
self._raw_data: dict[str, Any] = {}
self._collections: dict[str, JSONFileDocumentCollection[BaseDocument]] = {}
async def flush(self) -> None:
async with self._lock.writer_lock:
await self._flush_unlocked()
async def __aenter__(self) -> Self:
async with self._lock.reader_lock:
self._raw_data = await self._load_raw_data()
return self
async def __aexit__(
self,
exc_type: Optional[type[BaseException]],
exc_value: Optional[BaseException],
traceback: Optional[object],
) -> bool:
async with self._lock.writer_lock:
await self._flush_unlocked()
return False
async def _load_raw_data(
self,
) -> dict[str, Any]:
# Return an empty JSON object if the file is empty
if self.file_path.stat().st_size == 0:
return {}
async with aiofiles.open(self.file_path, "r", encoding="utf-8") as file:
return cast(dict[str, Any], json.loads(await file.read()))
async def _save_data(
self,
data: Mapping[str, Sequence[Mapping[str, Any]]],
) -> None:
async with aiofiles.open(self.file_path, mode="w", encoding="utf-8") as file:
json_string = json.dumps(
{
**self._raw_data,
**data,
},
ensure_ascii=False,
indent=2,
)
await file.write(json_string)
async def load_documents_with_loader(
self,
name: str,
document_loader: Callable[[BaseDocument], Awaitable[Optional[TDocument]]],
documents: Sequence[BaseDocument] | None = None,
) -> Sequence[TDocument]:
data: list[TDocument] = []
failed_migrations: list[BaseDocument] = []
collection_documents = documents or self._raw_data.get(name, [])
for doc in collection_documents:
try:
if loaded_doc := await document_loader(doc):
data.append(loaded_doc)
else:
self._logger.warning(f'Failed to load document "{doc}"')
failed_migrations.append(doc)
except Exception as e:
self._logger.error(
f"Failed to load document '{doc}' with error: {e}. Added to failed migrations collection."
)
failed_migrations.append(doc)
if failed_migrations:
failed_migrations_collection = await self.get_or_create_collection(
"failed_migrations", BaseDocument, identity_loader
)
for doc in failed_migrations:
await failed_migrations_collection.insert_one(doc)
return data
@override
async def create_collection(
self,
name: str,
schema: type[TDocument],
) -> JSONFileDocumentCollection[TDocument]:
self._collections[name] = JSONFileDocumentCollection(
database=self,
name=name,
schema=schema,
)
return cast(JSONFileDocumentCollection[TDocument], self._collections[name])
@override
async def get_collection(
self,
name: str,
schema: type[TDocument],
document_loader: Callable[[BaseDocument], Awaitable[Optional[TDocument]]],
) -> JSONFileDocumentCollection[TDocument]:
if collection := self._collections.get(name):
return cast(JSONFileDocumentCollection[TDocument], collection)
elif name in self._raw_data:
self._collections[name] = JSONFileDocumentCollection(
database=self,
name=name,
schema=schema,
data=await self.load_documents_with_loader(name, document_loader),
)
return cast(JSONFileDocumentCollection[TDocument], self._collections[name])
raise ValueError(f'Collection "{name}" does not exists')
@override
async def get_or_create_collection(
self,
name: str,
schema: type[TDocument],
document_loader: Callable[[BaseDocument], Awaitable[Optional[TDocument]]],
) -> JSONFileDocumentCollection[TDocument]:
if collection := self._collections.get(name):
return cast(JSONFileDocumentCollection[TDocument], collection)
elif name in self._raw_data:
self._collections[name] = JSONFileDocumentCollection(
database=self,
name=name,
schema=schema,
data=await self.load_documents_with_loader(name, document_loader),
)
return cast(JSONFileDocumentCollection[TDocument], self._collections[name])
self._collections[name] = JSONFileDocumentCollection(
database=self,
name=name,
schema=schema,
data=await self.load_documents_with_loader(name, document_loader),
)
return cast(JSONFileDocumentCollection[TDocument], self._collections[name])
@override
async def delete_collection(
self,
name: str,
) -> None:
if name in self._collections:
del self._collections[name]
return
raise ValueError(f'Collection "{name}" does not exists')
async def _flush_unlocked(self) -> None:
data = {}
for collection_name in self._collections:
data[collection_name] = self._collections[collection_name].documents
await self._save_data(data)
class JSONFileDocumentCollection(DocumentCollection[TDocument]):
def __init__(
self,
database: JSONFileDocumentDatabase,
name: str,
schema: type[TDocument],
data: Sequence[TDocument] | None = None,
) -> None:
self._database = database
self._name = name
self._schema = schema
self._op_counter = 0
self._lock = ReaderWriterLock()
self.documents = list(data) if data else []
@override
async def find(
self,
filters: Where,
limit: Optional[int] = None,
cursor: Optional[Cursor] = None,
sort_direction: Optional[SortDirection] = None,
) -> FindResult[TDocument]:
async with self._lock.reader_lock:
# First, filter documents
filtered_docs = [doc for doc in self.documents if matches_filters(filters, doc)]
# Sort by creation_utc with id as tiebreaker according to sort_direction
sort_direction = sort_direction or SortDirection.ASC
filtered_docs = self._apply_sort(filtered_docs, sort_direction)
# Apply cursor-based pagination if cursor is provided
if cursor:
filtered_docs = self._apply_cursor_filter(filtered_docs, cursor, sort_direction)
total_count = len(filtered_docs)
# Apply limit
has_more = False
next_cursor = None
if limit is not None and len(filtered_docs) > limit:
# There are more items beyond the limit
has_more = True
result_docs = filtered_docs[:limit]
# Generate next cursor from the last item if we have results
if result_docs:
last_doc = result_docs[-1]
next_cursor = Cursor(
creation_utc=str(last_doc.get("creation_utc", "")),
id=ObjectId(str(last_doc.get("id", ""))),
)
else:
result_docs = filtered_docs
return FindResult(
items=result_docs,
total_count=total_count,
has_more=has_more,
next_cursor=next_cursor,
)
def _apply_sort(
self,
documents: list[TDocument],
sort_direction: SortDirection,
) -> list[TDocument]:
docs = list(documents) # don't mutate input
# Sort by creation_utc with id as tiebreaker according to sort_direction
reverse_order = sort_direction == SortDirection.DESC
docs.sort(
key=lambda d: (
d.get("creation_utc") or "", # Primary sort: creation_utc
d.get("id") or "", # Tiebreaker: id
),
reverse=reverse_order,
)
return docs
def _apply_field_sort(
self,
documents: Sequence[TDocument],
sort: CollectionSort,
) -> list[TDocument]:
docs = list(documents)
for field_name, direction in reversed(sort):
docs.sort(
key=lambda d: cast(Any, d.get(field_name)),
reverse=direction == SortDirection.DESC,
)
return docs
def _apply_cursor_filter(
self,
documents: list[TDocument],
cursor: Cursor,
sort_direction: SortDirection,
) -> list[TDocument]:
result = []
for doc in documents:
doc_creation_utc = str(doc.get("creation_utc", ""))
doc_id = str(doc.get("id", ""))
if sort_direction == SortDirection.DESC:
# For descending order pagination, include documents that come after the cursor
# This matches the MongoDB query pattern:
# { "$or": [
# { "creation_utc": { "$lt": cursor.creation_utc } },
# { "creation_utc": cursor.creation_utc, "id": { "$lt": cursor.id } }
# ]}
if doc_creation_utc < cursor.creation_utc or (
doc_creation_utc == cursor.creation_utc and doc_id < cursor.id
):
result.append(doc)
else: # SortDirection.ASC
# For ascending order pagination, include documents that come after the cursor
# { "$or": [
# { "creation_utc": { "$gt": cursor_creation_utc } },
# { "creation_utc": cursor.creation_utc, "id": { "$gt": cursor.id } }
# ]}
if doc_creation_utc > cursor.creation_utc or (
doc_creation_utc == cursor.creation_utc and doc_id > cursor.id
):
result.append(doc)
return result
@override
async def find_one(
self,
filters: Where,
sort: Optional[CollectionSort] = None,
) -> Optional[TDocument]:
async with self._lock.reader_lock:
matching_documents = [doc for doc in self.documents if matches_filters(filters, doc)]
if sort:
matching_documents = self._apply_field_sort(matching_documents, sort)
for doc in matching_documents:
return doc
return None
@override
async def ensure_indexes(
self,
indexes: Sequence[CollectionIndex],
) -> None:
return None
@override
async def insert_one(
self,
document: TDocument,
) -> InsertResult:
ensure_is_total(document, self._schema)
async with self._lock.writer_lock:
self.documents.append(document)
await self._database.flush()
return InsertResult(acknowledged=True)
@override
async def update_one(
self,
filters: Where,
params: TDocument,
upsert: bool = False,
) -> UpdateResult[TDocument]:
async with self._lock.writer_lock:
for i, d in enumerate(self.documents):
if matches_filters(filters, d):
self.documents[i] = cast(TDocument, {**self.documents[i], **params})
await self._database.flush()
return UpdateResult(
acknowledged=True,
matched_count=1,
modified_count=1,
updated_document=self.documents[i],
)
if upsert:
await self.insert_one(params)
return UpdateResult(
acknowledged=True,
matched_count=0,
modified_count=0,
updated_document=params,
)
return UpdateResult(
acknowledged=True,
matched_count=0,
modified_count=0,
updated_document=None,
)
@override
async def delete_one(
self,
filters: Where,
) -> DeleteResult[TDocument]:
async with self._lock.writer_lock:
for i, d in enumerate(self.documents):
if matches_filters(filters, d):
document = self.documents.pop(i)
await self._database.flush()
return DeleteResult(
deleted_count=1, acknowledged=True, deleted_document=document
)
return DeleteResult(
acknowledged=True,
deleted_count=0,
deleted_document=None,
)
+310
View File
@@ -0,0 +1,310 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Any, Awaitable, Callable, Optional, Sequence
from bson import CodecOptions
from typing_extensions import Self
from parlant.core.loggers import Logger
from parlant.core.persistence.common import Cursor, SortDirection, Where, ObjectId
from parlant.core.persistence.document_database import (
CollectionIndex,
CollectionSort,
BaseDocument,
DeleteResult,
DocumentCollection,
DocumentDatabase,
FindResult,
InsertResult,
TDocument,
UpdateResult,
)
from pymongo import AsyncMongoClient
from pymongo.asynchronous.database import AsyncDatabase
from pymongo.asynchronous.collection import AsyncCollection
class MongoDocumentDatabase(DocumentDatabase):
def __init__(
self,
mongo_client: AsyncMongoClient[Any],
database_name: str,
logger: Logger,
):
self.mongo_client: AsyncMongoClient[Any] = mongo_client
self.database_name = database_name
self._logger = logger
self._database: Optional[AsyncDatabase[Any]] = None
self._collections: dict[str, MongoDocumentCollection[Any]] = {}
async def create_collection(
self,
name: str,
schema: type[TDocument],
) -> DocumentCollection[TDocument]:
if self._database is None:
raise Exception("underlying database missing.")
collection = await self._database.create_collection(
name=name,
codec_options=CodecOptions(document_class=schema),
)
self._collections[name] = MongoDocumentCollection(self, collection)
await self._collections[name].ensure_indexes(
[
CollectionIndex(
fields=(
("creation_utc", SortDirection.ASC),
("id", SortDirection.ASC),
)
)
]
)
return self._collections[name]
async def get_collection(
self,
name: str,
schema: type[TDocument],
document_loader: Callable[[BaseDocument], Awaitable[TDocument | None]],
) -> DocumentCollection[TDocument]:
if self._database is None:
raise Exception("underlying database missing.")
result_collection = self._database.get_collection(
name=name,
codec_options=CodecOptions(document_class=schema),
)
failed_migrations_collection_name = f"{self.database_name}_{name}_failed_migrations"
collection_existing_documents = result_collection.find({})
if failed_migrations_collection_name in await self._database.list_collection_names():
self._logger.info(f"deleting old `{failed_migrations_collection_name}` collection")
await self.delete_collection(failed_migrations_collection_name)
failed_migration_collection: Optional[DocumentCollection[TDocument]] = None
async for doc in collection_existing_documents:
try:
original_version = doc.get("version")
if loaded_doc := await document_loader(doc):
# Only rewrite if the document was actually migrated (version changed or new dict created)
if loaded_doc is not doc or loaded_doc.get("version") != original_version:
# Use _id for efficient lookup instead of the full document
await result_collection.replace_one({"_id": doc["_id"]}, loaded_doc)
continue
if failed_migration_collection is None:
self._logger.warning(
f"creating: `{failed_migrations_collection_name}` collection to store failed migrations..."
)
failed_migration_collection = await self.create_collection(
failed_migrations_collection_name, schema
)
self._logger.warning(f'failed to load document "{doc}"')
await failed_migration_collection.insert_one(doc)
await result_collection.delete_one(doc)
except Exception as e:
if failed_migration_collection is None:
self._logger.warning(
f"creating: `{failed_migrations_collection_name}` collection to store failed migrations..."
)
failed_migration_collection = await self.create_collection(
failed_migrations_collection_name, schema
)
self._logger.error(
f"failed to load document '{doc}' with error: {e}. Added to `{failed_migrations_collection_name}` collection."
)
await failed_migration_collection.insert_one(doc)
self._collections[name] = MongoDocumentCollection(self, result_collection)
await self._collections[name].ensure_indexes(
[
CollectionIndex(
fields=(
("creation_utc", SortDirection.ASC),
("id", SortDirection.ASC),
)
)
]
)
return self._collections[name]
async def get_or_create_collection(
self,
name: str,
schema: type[TDocument],
document_loader: Callable[[BaseDocument], Awaitable[TDocument | None]],
) -> DocumentCollection[TDocument]:
return await self.get_collection(name, schema, document_loader)
async def delete_collection(self, name: str) -> None:
if self._database is None:
raise Exception("underlying database missing.")
await self._database.drop_collection(name)
async def __aenter__(self) -> Self:
self._database = self.mongo_client[self.database_name]
return self
async def __aexit__(
self,
exc_type: Optional[type[BaseException]],
exc_value: Optional[BaseException],
traceback: Optional[object],
) -> bool:
if self._database is not None:
self._database = None
return False
class MongoDocumentCollection(DocumentCollection[TDocument]):
def __init__(
self,
mongo_document_database: MongoDocumentDatabase,
mongo_collection: AsyncCollection[TDocument],
) -> None:
self._database = mongo_document_database
self._collection = mongo_collection
async def find(
self,
filters: Where,
limit: Optional[int] = None,
cursor: Optional[Cursor] = None,
sort_direction: Optional[SortDirection] = None,
) -> FindResult[TDocument]:
query = dict(filters) if filters else {}
sort_direction = sort_direction or SortDirection.ASC
if cursor is not None:
if sort_direction == SortDirection.DESC:
cursor_conditions = [
{"creation_utc": {"$lt": cursor.creation_utc}},
{
"$and": [
{"creation_utc": cursor.creation_utc},
{"id": {"$lt": cursor.id}},
]
},
]
else:
cursor_conditions = [
{"creation_utc": {"$gt": cursor.creation_utc}},
{
"$and": [
{"creation_utc": cursor.creation_utc},
{"id": {"$gt": cursor.id}},
]
},
]
query["$or"] = cursor_conditions
# Sort by creation_utc with id as tiebreaker according to sort_direction
sort_order = -1 if sort_direction == SortDirection.DESC else 1
sort_spec = [("creation_utc", sort_order), ("id", sort_order)]
# Get one extra document to check if there are more
query_limit = (limit + 1) if limit else None
mongo_cursor = self._collection.find(query).sort(sort_spec)
if query_limit:
mongo_cursor = mongo_cursor.limit(query_limit)
items = await mongo_cursor.to_list(length=query_limit)
# Calculate pagination metadata
has_more = False
next_cursor = None
total_count = len(items)
if limit and len(items) > limit:
has_more = True
items = items[:limit] # Remove the extra item
# Create cursor from the last item
if items:
last_item = items[-1]
next_cursor = Cursor(
creation_utc=str(last_item.get("creation_utc", "")),
id=ObjectId(str(last_item.get("id", ""))),
)
return FindResult(
items=items, total_count=total_count, has_more=has_more, next_cursor=next_cursor
)
def _translate_sort(
self,
sort: CollectionSort,
) -> list[tuple[str, int]]:
return [
(field_name, -1 if direction == SortDirection.DESC else 1)
for field_name, direction in sort
]
async def find_one(
self,
filters: Where,
sort: Optional[CollectionSort] = None,
) -> TDocument | None:
mongo_sort = self._translate_sort(sort) if sort else None
result = await self._collection.find_one(filters, sort=mongo_sort)
return result
async def ensure_indexes(
self,
indexes: Sequence[CollectionIndex],
) -> None:
for index in indexes:
await self._collection.create_index(
self._translate_sort(index.fields),
unique=index.unique,
)
async def insert_one(self, document: TDocument) -> InsertResult:
insert_result = await self._collection.insert_one(document)
return InsertResult(acknowledged=insert_result.acknowledged)
async def update_one(
self,
filters: Where,
params: TDocument,
upsert: bool = False,
) -> UpdateResult[TDocument]:
update_result = await self._collection.update_one(filters, {"$set": params}, upsert)
result_document = await self._collection.find_one(filters)
return UpdateResult[TDocument](
update_result.acknowledged,
update_result.matched_count,
update_result.modified_count,
result_document,
)
async def delete_one(self, filters: Where) -> DeleteResult[TDocument]:
result_document = await self._collection.find_one(filters)
if result_document is None:
return DeleteResult(True, 0, None)
delete_result = await self._collection.delete_one(filters)
return DeleteResult(
delete_result.acknowledged,
deleted_count=delete_result.deleted_count,
deleted_document=result_document,
)
+771
View File
@@ -0,0 +1,771 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Maintainer: Tao Tang <ttan@habitus.dk>
from __future__ import annotations
import asyncio
import importlib
import json
import os
import re
from typing import (
Any,
Awaitable,
Callable,
Literal,
Mapping,
MutableMapping,
Optional,
Sequence,
cast,
)
from typing_extensions import Self
from parlant.core.loggers import Logger
from parlant.core.persistence.common import Cursor, ObjectId, SortDirection, Where, ensure_is_total
from parlant.core.persistence.document_database import (
CollectionIndex,
CollectionSort,
BaseDocument,
DeleteResult,
DocumentCollection,
DocumentDatabase,
FindResult,
InsertResult,
TDocument,
UpdateResult,
)
class SnowflakeAdapterError(Exception):
"""Raised for recoverable adapter errors."""
_IDENTIFIER_RE = re.compile(r"[^0-9A-Za-z_]")
def _sanitize_identifier(raw: str) -> str:
sanitized = _IDENTIFIER_RE.sub("_", raw).upper()
if not sanitized:
raise SnowflakeAdapterError("Snowflake identifier cannot be empty")
if sanitized[0].isdigit():
return f"_{sanitized}"
return sanitized
def _stringify(value: Any) -> Optional[str]:
if value is None:
return None
object_id_type = getattr(ObjectId, "__supertype__", str)
if isinstance(value, object_id_type):
return str(value)
return str(value)
def _load_connection_params_from_env() -> dict[str, Any]:
env = os.environ
required = [
"SNOWFLAKE_ACCOUNT",
"SNOWFLAKE_USER",
"SNOWFLAKE_WAREHOUSE",
"SNOWFLAKE_DATABASE",
"SNOWFLAKE_SCHEMA",
]
missing = [key for key in required if not env.get(key)]
if missing:
raise SnowflakeAdapterError(
"Missing Snowflake configuration. Set the following environment variables: "
+ ", ".join(missing)
)
params: dict[str, Any] = {
"account": env["SNOWFLAKE_ACCOUNT"],
"user": env["SNOWFLAKE_USER"],
"warehouse": env["SNOWFLAKE_WAREHOUSE"],
"database": env["SNOWFLAKE_DATABASE"],
"schema": env["SNOWFLAKE_SCHEMA"],
}
if env.get("SNOWFLAKE_ROLE"):
params["role"] = env["SNOWFLAKE_ROLE"]
token = env.get("SNOWFLAKE_TOKEN")
password = env.get("SNOWFLAKE_PASSWORD")
if token:
params["authenticator"] = "oauth"
params["token"] = token
elif password:
params["authenticator"] = env.get("SNOWFLAKE_AUTHENTICATOR", "snowflake")
params["password"] = password
else:
raise SnowflakeAdapterError(
"Provide either SNOWFLAKE_PASSWORD or SNOWFLAKE_TOKEN for authentication"
)
return params
FetchMode = Literal["none", "all", "one"]
class SnowflakeDocumentDatabase(DocumentDatabase):
def __init__(
self,
logger: Logger,
connection_params: Mapping[str, Any] | None = None,
*,
table_prefix: str | None = None,
connection_factory: Callable[[Mapping[str, Any]], Any] | None = None,
) -> None:
self._logger = logger
self._connection_params = (
dict(connection_params)
if connection_params is not None
else _load_connection_params_from_env()
)
self._table_prefix = _sanitize_identifier(table_prefix) if table_prefix else "PARLANT_"
self._connection_factory = connection_factory
self._connector_module: Any | None = None
self._snowflake_error: type[BaseException] | None = None
self._dict_cursor_cls: Any | None = None
self._connection: Any | None = None
self._collections: dict[str, SnowflakeDocumentCollection[Any]] = {}
self._initialized: set[str] = set()
self._init_locks: dict[str, asyncio.Lock] = {}
self._connection_lock = asyncio.Lock()
self._operation_lock = asyncio.Lock()
async def __aenter__(self) -> Self:
await self._ensure_connection()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: object | None,
) -> bool:
if self._connection is not None:
await asyncio.to_thread(self._connection.close)
self._connection = None
return False
async def create_collection(
self,
name: str,
schema: type[TDocument],
) -> SnowflakeDocumentCollection[TDocument]:
return await self._get_or_create_initialized_collection(
name,
schema,
document_loader=None,
)
async def get_collection(
self,
name: str,
schema: type[TDocument],
document_loader: Callable[[BaseDocument], Awaitable[Optional[TDocument]]],
) -> SnowflakeDocumentCollection[TDocument]:
return await self._get_or_create_initialized_collection(
name,
schema,
document_loader=document_loader,
)
async def get_or_create_collection(
self,
name: str,
schema: type[TDocument],
document_loader: Callable[[BaseDocument], Awaitable[Optional[TDocument]]],
) -> SnowflakeDocumentCollection[TDocument]:
return await self.get_collection(name, schema, document_loader)
async def delete_collection(self, name: str) -> None:
table = self._table_identifier(name)
failed_table = self._failed_table_identifier(name)
await self._execute(f"DROP TABLE IF EXISTS {table}")
await self._execute(f"DROP TABLE IF EXISTS {failed_table}")
self._collections.pop(name, None)
async def _get_or_create_initialized_collection(
self,
name: str,
schema: type[TDocument],
document_loader: Callable[[BaseDocument], Awaitable[Optional[TDocument]]] | None,
) -> SnowflakeDocumentCollection[TDocument]:
if name not in self._collections:
self._collections[name] = SnowflakeDocumentCollection(
database=self,
name=name,
schema=schema,
logger=self._logger,
)
collection = cast(SnowflakeDocumentCollection[TDocument], self._collections[name])
if name in self._initialized:
return collection
lock = self._init_locks.setdefault(name, asyncio.Lock())
async with lock:
if name in self._initialized:
return collection
create_stmt = f"""
CREATE TABLE IF NOT EXISTS {collection._table} (
ID STRING NOT NULL,
VERSION STRING,
CREATION_UTC STRING,
DATA VARIANT,
PRIMARY KEY (ID)
)
"""
await self._execute(create_stmt)
await self._execute(
f"""
CREATE TABLE IF NOT EXISTS {collection._failed_table} (
ID STRING,
DATA VARIANT
)
"""
)
if document_loader is not None:
await self.load_documents_with_loader(collection, document_loader)
self._initialized.add(name)
return collection
async def load_documents_with_loader(
self,
collection: SnowflakeDocumentCollection[TDocument],
document_loader: Callable[[BaseDocument], Awaitable[Optional[TDocument]]],
) -> None:
rows = await self._execute(
f"SELECT DATA FROM {collection._table}",
fetch="all",
)
failed: list[BaseDocument] = []
for row in rows or []:
doc = collection._row_to_document(row)
try:
migrated = await document_loader(doc)
except Exception as exc: # pragma: no cover
self._logger.error(
f"Failed to load document '{doc.get('id')}' in collection '{collection._name}': {exc}"
)
failed.append(doc)
continue
if migrated is None:
failed.append(doc)
continue
if migrated is not doc:
await collection._replace_document(migrated)
if failed:
await collection._persist_failed_documents(failed)
await collection._delete_documents([doc["id"] for doc in failed if "id" in doc])
async def _execute(
self,
sql: str,
params: Mapping[str, Any] | Sequence[Any] | None = None,
*,
fetch: FetchMode = "none",
) -> Any:
await self._ensure_connection()
async with self._operation_lock:
return await asyncio.to_thread(self._run_query, sql, params, fetch)
def _run_query(
self,
sql: str,
params: Mapping[str, Any] | Sequence[Any] | None,
fetch: FetchMode,
) -> Any:
assert self._connection is not None
cursor = (
self._connection.cursor(self._dict_cursor_cls)
if self._dict_cursor_cls is not None
else self._connection.cursor()
)
try:
cursor.execute(sql, params)
if fetch == "all":
return cursor.fetchall()
if fetch == "one":
return cursor.fetchone()
return None
except Exception as exc: # pragma: no cover - wrapped below
if self._snowflake_error and isinstance(exc, self._snowflake_error):
raise SnowflakeAdapterError(f"Snowflake query failed: {exc}") from exc
raise
finally:
cursor.close()
async def _ensure_connection(self) -> None:
if self._connection is not None:
return
async with self._connection_lock:
if self._connection is not None:
return
self._import_connector()
if self._connection_factory is not None:
self._connection = self._connection_factory(self._connection_params)
else:
assert self._connector_module is not None
self._connection = await asyncio.to_thread(
self._connector_module.connect,
**self._connection_params,
)
def _import_connector(self) -> None:
if self._connector_module is not None:
return
try:
connector_module = importlib.import_module("snowflake.connector")
except ImportError as exc: # pragma: no cover - exercised when dependency missing
raise SnowflakeAdapterError(
"Snowflake adapter requires snowflake-connector-python. Install parlant[snowflake]."
) from exc
self._connector_module = connector_module
self._dict_cursor_cls = getattr(connector_module, "DictCursor", None)
try:
errors_module = importlib.import_module("snowflake.connector.errors")
self._snowflake_error = getattr(errors_module, "Error", None)
except ImportError:
self._snowflake_error = None
def _table_identifier(self, name: str) -> str:
return f'"{_sanitize_identifier(self._table_prefix + name)}"'
def _failed_table_identifier(self, name: str) -> str:
return f'"{_sanitize_identifier(self._table_prefix + name + "_failed_migrations")}"'
class SnowflakeDocumentCollection(DocumentCollection[TDocument]):
INDEXED_FIELDS = {
"id",
"version",
"creation_utc",
}
def __init__(
self,
database: SnowflakeDocumentDatabase,
name: str,
schema: type[TDocument],
logger: Logger,
) -> None:
self._database = database
self._name = name
self._schema = schema
self._logger = logger
self._table = self._database._table_identifier(name)
self._failed_table = self._database._failed_table_identifier(name)
async def find(
self,
filters: Where,
limit: Optional[int] = None,
cursor: Optional[Cursor] = None,
sort_direction: Optional[SortDirection] = None,
) -> FindResult[TDocument]:
sort_direction = sort_direction or SortDirection.ASC
base_clause, base_params = _build_where_clause(filters, self.INDEXED_FIELDS)
params: dict[str, Any] = dict(base_params)
cursor_clause, cursor_params = _build_cursor_clause(cursor, sort_direction)
clause = base_clause
if cursor_clause:
clause = f"{clause} AND {cursor_clause}" if clause else f"WHERE {cursor_clause}"
params.update(cursor_params)
order_direction = "DESC" if sort_direction == SortDirection.DESC else "ASC"
order_by = f"ORDER BY CREATION_UTC {order_direction}, ID {order_direction}"
query_limit = (limit + 1) if limit else None
limit_sql = f" LIMIT {query_limit}" if query_limit else ""
sql = f"SELECT DATA FROM {self._table}"
if clause:
sql += f" {clause}"
sql += f" {order_by}{limit_sql}"
rows = await self._database._execute(sql, params or None, fetch="all")
documents = [cast(TDocument, self._row_to_document(row)) for row in rows or []]
total_count = len(documents)
has_more = False
next_cursor = None
if limit and len(documents) > limit:
has_more = True
documents = documents[:limit]
if documents:
last_doc = documents[-1]
creation_utc = last_doc.get("creation_utc")
identifier = last_doc.get("id")
if creation_utc is not None and identifier is not None:
next_cursor = Cursor(
creation_utc=str(creation_utc),
id=ObjectId(str(identifier)),
)
return FindResult(
items=documents,
total_count=total_count,
has_more=has_more,
next_cursor=next_cursor,
)
def _apply_field_sort(
self,
documents: Sequence[TDocument],
sort: CollectionSort,
) -> list[TDocument]:
docs = list(documents)
for field_name, direction in reversed(sort):
docs.sort(
key=lambda d: cast(Any, d.get(field_name)),
reverse=direction == SortDirection.DESC,
)
return docs
async def find_one(
self,
filters: Where,
sort: Optional[CollectionSort] = None,
) -> Optional[TDocument]:
if sort:
matching_documents = list((await self.find(filters=filters)).items)
sorted_documents = self._apply_field_sort(matching_documents, sort)
return sorted_documents[0] if sorted_documents else None
clause, params = _build_where_clause(filters, self.INDEXED_FIELDS)
sql = f"SELECT DATA FROM {self._table} {clause} LIMIT 1"
row = await self._database._execute(sql, params, fetch="one")
if not row:
return None
return cast(TDocument, self._row_to_document(row))
async def ensure_indexes(
self,
indexes: Sequence[CollectionIndex],
) -> None:
return None
async def insert_one(self, document: TDocument) -> InsertResult:
ensure_is_total(document, self._schema)
params = self._serialize_document(document)
sql = f"""
INSERT INTO {self._table}
(ID, VERSION, CREATION_UTC, DATA)
SELECT
V.ID,
V.VERSION,
V.CREATION_UTC,
PARSE_JSON(V.DATA_RAW)
FROM VALUES (
%(id)s,
%(version)s,
%(creation_utc)s,
%(data)s
) AS V(ID, VERSION, CREATION_UTC, DATA_RAW)
"""
await self._database._execute(sql, params)
return InsertResult(acknowledged=True)
async def update_one(
self,
filters: Where,
params: TDocument,
upsert: bool = False,
) -> UpdateResult[TDocument]:
existing = await self.find_one(filters)
if existing:
updated_document = cast(TDocument, {**existing, **params})
await self._replace_document(updated_document)
return UpdateResult(
True,
matched_count=1,
modified_count=1,
updated_document=updated_document,
)
if upsert:
await self.insert_one(params)
return UpdateResult(True, matched_count=0, modified_count=0, updated_document=params)
return UpdateResult(True, matched_count=0, modified_count=0, updated_document=None)
async def delete_one(self, filters: Where) -> DeleteResult[TDocument]:
existing = await self.find_one(filters)
if not existing:
return DeleteResult(True, deleted_count=0, deleted_document=None)
identifier = existing.get("id")
if identifier is None:
return DeleteResult(True, deleted_count=0, deleted_document=None)
await self._delete_documents([identifier])
return DeleteResult(True, deleted_count=1, deleted_document=existing)
def _row_to_document(self, row: Any) -> BaseDocument:
if isinstance(row, Mapping):
data = row.get("DATA")
else:
data = row[0]
if isinstance(data, str):
return cast(BaseDocument, json.loads(data))
return cast(BaseDocument, data)
async def _replace_document(self, document: TDocument) -> None:
params = self._serialize_document(document)
sql = f"""
UPDATE {self._table}
SET VERSION=%(version)s,
CREATION_UTC=%(creation_utc)s,
DATA=PARSE_JSON(%(data)s)
WHERE ID=%(id)s
"""
await self._database._execute(sql, params)
async def _delete_documents(self, identifiers: Sequence[Any]) -> None:
if not identifiers:
return
placeholders = ", ".join(f"%(id_{i})s" for i in range(len(identifiers)))
params = {f"id_{i}": _stringify(value) for i, value in enumerate(identifiers)}
sql = f"DELETE FROM {self._table} WHERE ID IN ({placeholders})"
await self._database._execute(sql, params)
async def _persist_failed_documents(self, documents: Sequence[BaseDocument]) -> None:
if not documents:
return
for doc in documents:
params = {
"id": _stringify(doc.get("id")),
"data": json.dumps(doc, ensure_ascii=False),
}
sql = f"""
INSERT INTO {self._failed_table} (ID, DATA)
SELECT
V.ID,
PARSE_JSON(V.DATA_RAW)
FROM VALUES (%(id)s, %(data)s) AS V(ID, DATA_RAW)
"""
await self._database._execute(sql, params)
def _serialize_document(self, document: TDocument) -> MutableMapping[str, Any]:
return {
"id": _stringify(document["id"]),
"version": document.get("version"),
"creation_utc": document.get("creation_utc"),
"data": json.dumps(document, ensure_ascii=False),
}
def _build_where_clause(filters: Where, indexed_fields: set[str]) -> tuple[str, Mapping[str, Any]]:
if not filters:
return "", {}
translator = _WhereTranslator(indexed_fields)
clause = translator.render(filters)
if not clause:
return "", {}
return f"WHERE {clause}", translator.params
def _build_cursor_clause(
cursor: Cursor | None,
sort_direction: SortDirection,
) -> tuple[str, Mapping[str, Any]]:
if cursor is None:
return "", {}
creation_operator = "<" if sort_direction == SortDirection.DESC else ">"
id_operator = "<" if sort_direction == SortDirection.DESC else ">"
clause = (
f"(CREATION_UTC {creation_operator} %(cursor_creation)s "
f"OR (CREATION_UTC = %(cursor_creation)s AND ID {id_operator} %(cursor_id)s))"
)
params = {
"cursor_creation": cursor.creation_utc,
"cursor_id": str(cursor.id),
}
return clause, params
class _WhereTranslator:
def __init__(self, indexed_fields: set[str]) -> None:
self._indexed_fields = indexed_fields
self._params: dict[str, Any] = {}
self._counter = 0
@property
def params(self) -> Mapping[str, Any]:
return self._params
def render(self, filters: Where) -> str:
return self._render(filters)
def _render(self, filters: Where) -> str:
if not filters:
return ""
if isinstance(filters, Mapping):
fragments: list[str] = []
for key, value in filters.items():
if key == "$and":
parts = [self._render(part) for part in cast(Sequence[Where], value)]
parts = [part for part in parts if part]
if parts:
fragments.append("(" + " AND ".join(parts) + ")")
elif key == "$or":
parts = [self._render(part) for part in cast(Sequence[Where], value)]
parts = [part for part in parts if part]
if parts:
fragments.append("(" + " OR ".join(parts) + ")")
else:
fragments.append(self._render_field(key, value))
return " AND ".join(part for part in fragments if part)
raise SnowflakeAdapterError("Unsupported filter format for Snowflake adapter")
def _render_field(self, field: str, condition: Any) -> str:
if not isinstance(condition, Mapping):
return self._equality_clause(field, condition)
clauses: list[str] = []
for operator, operand in condition.items():
if operator == "$eq":
clauses.append(self._equality_clause(field, operand))
elif operator in {"$gt", "$gte", "$lt", "$lte", "$ne"}:
clauses.append(self._comparison_clause(field, operator, operand))
elif operator == "$in":
clauses.append(self._membership_clause(field, operand, negate=False))
elif operator == "$nin":
clauses.append(self._membership_clause(field, operand, negate=True))
else:
raise SnowflakeAdapterError(
f"Unsupported operator '{operator}' in Snowflake filter"
)
return " AND ".join(clauses)
def _membership_clause(self, field: str, operand: Any, *, negate: bool) -> str:
values = list(operand or [])
if not values:
return "1=1" if negate else "1=0"
column, needs_variant = self._column_expr(field)
placeholders: list[str] = []
for value in values:
name = self._add_param(value)
placeholders.append(self._wrap_value(name, needs_variant))
operator = "NOT IN" if negate else "IN"
return f"{column} {operator} (" + ", ".join(placeholders) + ")"
def _equality_clause(self, field: str, operand: Any) -> str:
name = self._add_param(operand)
column, needs_variant = self._column_expr(field)
return f"{column} = {self._wrap_value(name, needs_variant)}"
def _column_expr(self, field: str) -> tuple[str, bool]:
sanitized = _sanitize_identifier(field)
if field in self._indexed_fields:
return f'"{sanitized}"', False
json_path = json.dumps(field)
return f"DATA:{json_path}", True
def _wrap_value(self, placeholder: str, needs_variant: bool) -> str:
return f"TO_VARIANT({placeholder})" if needs_variant else placeholder
def _comparison_clause(self, field: str, operator: str, operand: Any) -> str:
sql_operator = {
"$gt": ">",
"$gte": ">=",
"$lt": "<",
"$lte": "<=",
"$ne": "!=",
}[operator]
name = self._add_param(operand)
column, needs_variant = self._column_expr(field)
return f"{column} {sql_operator} {self._wrap_value(name, needs_variant)}"
def _add_param(self, value: Any) -> str:
name = f"param_{self._counter}"
self._counter += 1
object_id_type = getattr(ObjectId, "__supertype__", str)
if isinstance(value, object_id_type):
value = str(value)
self._params[name] = value
return f"%({name})s"
__all__ = [
"SnowflakeAdapterError",
"SnowflakeDocumentCollection",
"SnowflakeDocumentDatabase",
]
+316
View File
@@ -0,0 +1,316 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from typing import Any, Awaitable, Callable, Optional, Sequence, cast
from typing_extensions import override
from typing_extensions import get_type_hints
from parlant.core.persistence.common import (
Cursor,
SortDirection,
matches_filters,
Where,
ObjectId,
ensure_is_total,
)
from parlant.core.persistence.document_database import (
CollectionIndex,
CollectionSort,
BaseDocument,
DeleteResult,
DocumentCollection,
DocumentDatabase,
FindResult,
InsertResult,
TDocument,
UpdateResult,
)
class TransientDocumentDatabase(DocumentDatabase):
def __init__(self) -> None:
self._collections: dict[str, TransientDocumentCollection[BaseDocument]] = {}
@override
async def create_collection(
self,
name: str,
schema: type[TDocument],
) -> TransientDocumentCollection[TDocument]:
annotations = get_type_hints(schema)
assert "id" in annotations and annotations["id"] == ObjectId
self._collections[name] = TransientDocumentCollection(
name=name,
schema=schema,
)
return cast(TransientDocumentCollection[TDocument], self._collections[name])
@override
async def get_collection(
self,
name: str,
schema: type[TDocument],
document_loader: Callable[[BaseDocument], Awaitable[Optional[TDocument]]],
) -> TransientDocumentCollection[TDocument]:
if name in self._collections:
return cast(TransientDocumentCollection[TDocument], self._collections[name])
raise ValueError(f'Collection "{name}" does not exist')
@override
async def get_or_create_collection(
self,
name: str,
schema: type[TDocument],
document_loader: Callable[[BaseDocument], Awaitable[Optional[TDocument]]],
) -> TransientDocumentCollection[TDocument]:
if collection := self._collections.get(name):
return cast(TransientDocumentCollection[TDocument], collection)
annotations = get_type_hints(schema)
assert "id" in annotations and annotations["id"] == ObjectId
return await self.create_collection(
name=name,
schema=schema,
)
@override
async def delete_collection(
self,
name: str,
) -> None:
if name in self._collections:
del self._collections[name]
else:
raise ValueError(f'Collection "{name}" does not exist')
class TransientDocumentCollection(DocumentCollection[TDocument]):
def __init__(
self,
name: str,
schema: type[TDocument],
data: Optional[Sequence[TDocument]] = None,
) -> None:
self._name = name
self._schema = schema
self._documents = list(data) if data else []
@override
async def find(
self,
filters: Where,
limit: Optional[int] = None,
cursor: Optional[Cursor] = None,
sort_direction: Optional[SortDirection] = None,
) -> FindResult[TDocument]:
# First, filter documents
filtered_docs = [doc for doc in self._documents if matches_filters(filters, doc)]
# Sort by creation_utc with id as tiebreaker according to sort_direction
sort_direction = sort_direction or SortDirection.ASC
filtered_docs = self._apply_sort(filtered_docs, sort_direction)
# Apply cursor-based pagination if cursor is provided
if cursor:
filtered_docs = self._apply_cursor_filter(filtered_docs, cursor, sort_direction)
total_count = len(filtered_docs)
# Apply limit
has_more = False
next_cursor = None
if limit is not None and len(filtered_docs) > limit:
# There are more items beyond the limit
has_more = True
result_docs = filtered_docs[:limit]
# Generate next cursor from the last item if we have results
if result_docs:
last_doc = result_docs[-1]
next_cursor = Cursor(
creation_utc=str(last_doc.get("creation_utc", "")),
id=ObjectId(str(last_doc.get("id", ""))),
)
else:
result_docs = filtered_docs
return FindResult(
items=result_docs,
total_count=total_count,
has_more=has_more,
next_cursor=next_cursor,
)
def _apply_sort(
self,
documents: list[TDocument],
sort_direction: SortDirection,
) -> list[TDocument]:
docs = list(documents) # don't mutate input
# Sort by creation_utc with id as tiebreaker according to sort_direction
reverse_order = sort_direction == SortDirection.DESC
docs.sort(
key=lambda d: (
d.get("creation_utc") or "", # Primary sort: creation_utc
d.get("id") or "", # Tiebreaker: id
),
reverse=reverse_order,
)
return docs
def _apply_field_sort(
self,
documents: Sequence[TDocument],
sort: CollectionSort,
) -> list[TDocument]:
docs = list(documents)
for field_name, direction in reversed(sort):
docs.sort(
key=lambda d: cast(Any, d.get(field_name)),
reverse=direction == SortDirection.DESC,
)
return docs
def _apply_cursor_filter(
self,
documents: list[TDocument],
cursor: Cursor,
sort_direction: SortDirection,
) -> list[TDocument]:
cursor_creation_utc = str(cursor.creation_utc)
cursor_id = str(cursor.id)
result = []
for doc in documents:
doc_creation_utc = str(doc.get("creation_utc", ""))
doc_id = str(doc.get("id", ""))
if sort_direction == SortDirection.DESC:
# For descending order pagination, include documents that come after the cursor
# This matches the MongoDB query pattern:
# { "$or": [
# { "creation_utc": { "$lt": cursor_creation_utc } },
# { "creation_utc": cursor_creation_utc, "id": { "$lt": cursor_id } }
# ]}
if doc_creation_utc < cursor_creation_utc or (
doc_creation_utc == cursor_creation_utc and doc_id < cursor_id
):
result.append(doc)
else: # SortDirection.ASC
# For ascending order pagination, include documents that come after the cursor
# { "$or": [
# { "creation_utc": { "$gt": cursor_creation_utc } },
# { "creation_utc": cursor_creation_utc, "id": { "$gt": cursor_id } }
# ]}
if doc_creation_utc > cursor_creation_utc or (
doc_creation_utc == cursor_creation_utc and doc_id > cursor_id
):
result.append(doc)
return result
@override
async def find_one(
self,
filters: Where,
sort: Optional[CollectionSort] = None,
) -> Optional[TDocument]:
matching_documents = [doc for doc in self._documents if matches_filters(filters, doc)]
if sort:
matching_documents = self._apply_field_sort(matching_documents, sort)
for doc in matching_documents:
return doc
return None
@override
async def ensure_indexes(
self,
indexes: Sequence[CollectionIndex],
) -> None:
return None
@override
async def insert_one(
self,
document: TDocument,
) -> InsertResult:
ensure_is_total(document, self._schema)
self._documents.append(document)
return InsertResult(acknowledged=True)
@override
async def update_one(
self,
filters: Where,
params: TDocument,
upsert: bool = False,
) -> UpdateResult[TDocument]:
for i, d in enumerate(self._documents):
if matches_filters(filters, d):
self._documents[i] = cast(TDocument, {**self._documents[i], **params})
return UpdateResult(
acknowledged=True,
matched_count=1,
modified_count=1,
updated_document=self._documents[i],
)
if upsert:
await self.insert_one(params)
return UpdateResult(
acknowledged=True,
matched_count=0,
modified_count=0,
updated_document=params,
)
return UpdateResult(
acknowledged=True,
matched_count=0,
modified_count=0,
updated_document=None,
)
@override
async def delete_one(
self,
filters: Where,
) -> DeleteResult[TDocument]:
for i, d in enumerate(self._documents):
if matches_filters(filters, d):
document = self._documents.pop(i)
return DeleteResult(deleted_count=1, acknowledged=True, deleted_document=document)
return DeleteResult(
acknowledged=True,
deleted_count=0,
deleted_document=None,
)
@@ -0,0 +1,156 @@
import os
from typing import Any, MutableMapping
import structlog
from types import TracebackType
from typing_extensions import Self, override
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
OTLPLogExporter as GrpcOTLPLogExporter,
)
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
OTLPLogExporter as HttpOTLPLogExporter,
)
from parlant.core.loggers import LogLevel, TracingLogger
from parlant.core.tracer import Tracer
class OpenTelemetryLogger(TracingLogger):
"""TracingLogger with OpenTelemetry log export via OTLP (gRPC or HTTP)."""
def __init__(
self,
tracer: Tracer,
log_level: LogLevel = LogLevel.DEBUG,
logger_id: str | None = None,
) -> None:
super().__init__(tracer=tracer, log_level=log_level, logger_id=logger_id)
self._service_name = os.getenv("OTEL_SERVICE_NAME", "parlant")
self._logger_provider: LoggerProvider
self._log_exporter: GrpcOTLPLogExporter | HttpOTLPLogExporter
self._log_processor: BatchLogRecordProcessor
self._logging_handler: LoggingHandler
async def __aenter__(self) -> Self:
resource = Resource.create({"service.name": self._service_name})
endpoint = os.environ["OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"]
insecure = os.getenv("OTEL_EXPORTER_OTLP_INSECURE", "false").lower() == "true"
protocol = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc").lower()
match protocol:
case "http/protobuf":
self._log_exporter = HttpOTLPLogExporter(endpoint=endpoint)
case "http/json":
raise ValueError(
"http/json protocol is not supported for logs exporter. please use http/protobuf or grpc."
)
case "grpc":
self._log_exporter = GrpcOTLPLogExporter(
endpoint=endpoint,
insecure=insecure,
)
case _:
raise ValueError(f"Unsupported OTLP protocol: {protocol}")
self._logger_provider = LoggerProvider(resource=resource)
self._log_processor = BatchLogRecordProcessor(
exporter=self._log_exporter,
schedule_delay_millis=2000,
)
self._logger_provider.add_log_record_processor(self._log_processor)
self._logging_handler = LoggingHandler(
level=self.log_level.to_logging_level(),
logger_provider=self._logger_provider,
)
self.raw_logger.addHandler(self._logging_handler)
self._inject_structlog_processors()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> bool:
self._logger_provider.shutdown() # type: ignore
self.raw_logger.removeHandler(self._logging_handler)
return False
@override
def set_level(self, log_level: LogLevel) -> None:
super().set_level(log_level)
if self._logging_handler is not None:
self._logging_handler.setLevel(log_level.to_logging_level())
def _inject_structlog_processors(self) -> None:
"""Add trace_id/scopes as structured fields (OTEL attributes)."""
def _add_attributes(
_: Any, # logger
method: str,
event_dict: MutableMapping[str, Any],
) -> MutableMapping[str, Any]:
level = event_dict.get("actual_level", event_dict.get("level", method))
event_dict.pop("actual_level", None)
event_dict.pop("level", None)
event_dict["severity_text"] = str(level).upper()
event_dict["trace_id"] = self._tracer.trace_id
event_dict["span_id"] = self._tracer.span_id
if scope := self.current_scope:
event_dict["scope"] = scope
return event_dict
self._logger = structlog.wrap_logger(
self.raw_logger,
processors=[
structlog.stdlib.add_log_level,
_add_attributes,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.stdlib.render_to_log_kwargs,
],
wrapper_class=structlog.make_filtering_bound_logger(
0
), # Avoids doing the level check twice.
)
@override
def trace(self, message: str) -> None:
if self.log_level != LogLevel.TRACE:
return
self._logger.debug(message, actual_level="trace")
@override
def debug(self, message: str) -> None:
self._logger.debug(message)
@override
def info(self, message: str) -> None:
self._logger.info(message)
@override
def warning(self, message: str) -> None:
self._logger.warning(message)
@override
def error(self, message: str) -> None:
self._logger.error(message)
@override
def critical(self, message: str) -> None:
self._logger.critical(message)
+129
View File
@@ -0,0 +1,129 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from collections import deque
from dataclasses import dataclass
from typing import Any
from fastapi import WebSocket
from typing_extensions import override
from parlant.core.engines.alpha.entity_context import EntityContext
from parlant.core.common import UniqueId, generate_id
from parlant.core.tracer import Tracer
from parlant.core.loggers import TracingLogger, LogLevel
@dataclass(frozen=True)
class WebSocketSubscription:
socket: WebSocket
expiration: asyncio.Event
class WebSocketLogger(TracingLogger):
def __init__(
self,
tracer: Tracer,
log_level: LogLevel = LogLevel.DEBUG,
logger_id: str | None = None,
) -> None:
super().__init__(tracer, log_level, logger_id)
self._message_queue = deque[Any]()
self._messages_in_queue = asyncio.Semaphore(0)
self._socket_subscriptions: dict[UniqueId, WebSocketSubscription] = {}
self._lock = asyncio.Lock()
def _enqueue_message(self, timestamp: str, level: str, message: str) -> None:
payload = {
"level": level,
"trace_id": self._tracer.trace_id,
"message": message,
}
if context_creation := EntityContext.get_context_creation():
payload["message"] = f"[T+{round(context_creation.elapsed, 3)}s]{message}"
self._message_queue.append(payload)
self._messages_in_queue.release()
async def subscribe(self, web_socket: WebSocket) -> WebSocketSubscription:
socket_id = generate_id()
subscription = WebSocketSubscription(web_socket, asyncio.Event())
async with self._lock:
self._socket_subscriptions[socket_id] = subscription
return subscription
def _timestamp(self) -> str:
return round(asyncio.get_event_loop().time(), 3).__str__()
@override
def trace(self, message: str) -> None:
self._enqueue_message(self._timestamp(), "TRACE", f"{self.current_scope} {message}")
@override
def debug(self, message: str) -> None:
self._enqueue_message(self._timestamp(), "DEBUG", f"{self.current_scope} {message}")
@override
def info(self, message: str) -> None:
self._enqueue_message(self._timestamp(), "INFO", f"{self.current_scope} {message}")
@override
def warning(self, message: str) -> None:
self._enqueue_message(self._timestamp(), "WARNING", f"{self.current_scope} {message}")
@override
def error(self, message: str) -> None:
self._enqueue_message(self._timestamp(), "ERROR", f"{self.current_scope} {message}")
@override
def critical(self, message: str) -> None:
self._enqueue_message(self._timestamp(), "CRITICAL", f"{self.current_scope} {message}")
async def start(self) -> None:
try:
while True:
try:
await self._messages_in_queue.acquire()
payload = self._message_queue.popleft()
if not self._socket_subscriptions:
await asyncio.sleep(0)
continue
async with self._lock:
socket_subscriptions = dict(self._socket_subscriptions)
expired_ids = set()
for socket_id, subscription in socket_subscriptions.items():
try:
await subscription.socket.send_json(payload)
except Exception:
expired_ids.add(socket_id)
async with self._lock:
for socket_id in expired_ids:
subscription = self._socket_subscriptions.pop(socket_id)
subscription.expiration.set()
except asyncio.CancelledError:
return
finally:
async with self._lock:
for socket_id, subscription in self._socket_subscriptions.items():
subscription.expiration.set()
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import asyncio
import os
from types import TracebackType
from typing import AsyncGenerator, Mapping
from typing_extensions import override, Self
from contextlib import asynccontextmanager
from opentelemetry import metrics
from opentelemetry.metrics import Counter as OTelCounter, Histogram as OTelHistogram
from opentelemetry.sdk.metrics import (
MeterProvider,
)
from opentelemetry.sdk.metrics.export import (
PeriodicExportingMetricReader,
)
from opentelemetry.sdk.resources import Resource
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
OTLPMetricExporter as GrpcOTLPMetricExporter,
)
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
OTLPMetricExporter as HttpOTLPMetricExporter,
)
from parlant.core.meter import Counter, DurationHistogram, Meter
class OpenTelemetryCounter(Counter):
def __init__(self, otel_counter: OTelCounter) -> None:
self._otel_counter = otel_counter
@override
async def increment(
self,
value: int,
attributes: Mapping[str, str] | None = None,
) -> None:
self._otel_counter.add(value, attributes)
class OpenTelemetryHistogram(DurationHistogram):
def __init__(self, otel_histogram: OTelHistogram) -> None:
self._otel_histogram = otel_histogram
@override
async def record(
self,
value: float,
attributes: Mapping[str, str] | None = None,
) -> None:
self._otel_histogram.record(value, attributes)
@override
@asynccontextmanager
async def measure(
self,
attributes: Mapping[str, str] | None = None,
) -> AsyncGenerator[None, None]:
start_time = asyncio.get_running_loop().time()
try:
yield
finally:
duration = (
asyncio.get_running_loop().time() - start_time
) * 1000 # Convert to milliseconds
await self.record(duration, attributes)
class OpenTelemetryMeter(Meter):
def __init__(self) -> None:
self._service_name = os.getenv("OTEL_SERVICE_NAME", "parlant")
self._meter: metrics.Meter
self._metric_exporter: GrpcOTLPMetricExporter | HttpOTLPMetricExporter
self._meter_provider: MeterProvider
async def __aenter__(self) -> Self:
resource = Resource.create({"service.name": self._service_name})
endpoint = os.environ["OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"]
insecure = os.getenv("OTEL_EXPORTER_OTLP_INSECURE", "false").lower() == "true"
protocol = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc").lower()
match protocol:
case "http/protobuf":
self._metric_exporter = HttpOTLPMetricExporter(endpoint=endpoint)
case "http/json":
raise ValueError(
"http/json protocol is not supported for metrics exporter. please use http/protobuf or grpc."
)
case "grpc":
self._metric_exporter = GrpcOTLPMetricExporter(
endpoint=endpoint,
insecure=insecure,
)
case _:
raise ValueError(f"Unsupported OTLP protocol: {protocol}")
metric_reader = PeriodicExportingMetricReader(
exporter=self._metric_exporter,
export_interval_millis=int(os.getenv("OTEL_METRIC_EXPORT_INTERVAL", "3000")),
)
self._meter_provider = MeterProvider(
resource=resource,
metric_readers=[metric_reader],
)
metrics.set_meter_provider(self._meter_provider)
self._meter = metrics.get_meter(__name__)
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> bool:
self._meter_provider.force_flush()
self._meter_provider.shutdown()
return False
@override
def create_counter(
self,
name: str,
description: str,
) -> Counter:
otel_counter = self._meter.create_counter(
name=name,
description=description,
)
return OpenTelemetryCounter(otel_counter)
@override
def create_custom_histogram(
self,
name: str,
description: str,
unit: str,
) -> OpenTelemetryHistogram:
otel_histogram = self._meter.create_histogram(
name=name,
description=description,
unit=unit,
)
return OpenTelemetryHistogram(otel_histogram)
@override
def create_duration_histogram(
self,
name: str,
description: str,
) -> OpenTelemetryHistogram:
return self.create_custom_histogram(name, description, "ms")
@@ -0,0 +1,301 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import time
from pydantic import ValidationError
from anthropic import (
APIConnectionError,
APIResponseValidationError,
APITimeoutError,
AsyncAnthropic,
InternalServerError,
RateLimitError,
) # type: ignore
from typing import Any, Mapping
from typing_extensions import override
import jsonfinder # type: ignore
import os
from parlant.adapters.nlp.common import normalize_json_output, record_llm_metrics
from parlant.adapters.nlp.hugging_face import JinaAIEmbedder
from parlant.core.engines.alpha.canned_response_generator import CannedResponseSelectionSchema
from parlant.core.engines.alpha.guideline_matching.generic.disambiguation_batch import (
DisambiguationGuidelineMatchesSchema,
)
from parlant.core.engines.alpha.guideline_matching.generic.journey.journey_backtrack_node_selection import (
JourneyBacktrackNodeSelectionSchema,
)
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.tracer import Tracer
from parlant.core.meter import Meter
from parlant.core.nlp.embedding import Embedder
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
SchematicGenerationResult,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.loggers import Logger
from parlant.core.nlp.moderation import ModerationService, NoModeration
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.service import (
EmbedderHints,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.generation import StreamingTextGenerator
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.health import HealthReporter
class AnthropicEstimatingTokenizer(EstimatingTokenizer):
def __init__(self, client: AsyncAnthropic, model_name: str) -> None:
self._client = client
self.model_name = model_name
@override
async def estimate_token_count(self, prompt: str) -> int:
result = await self._client.messages.count_tokens(
model=self.model_name,
messages=[{"role": "assistant", "content": prompt}],
)
return result.input_tokens # type: ignore[no-any-return]
class AnthropicAISchematicGenerator(BaseSchematicGenerator[T]):
supported_hints = ["temperature"]
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._client = AsyncAnthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
self._estimating_tokenizer = AnthropicEstimatingTokenizer(self._client, model_name)
@property
@override
def id(self) -> str:
return f"anthropic/{self.model_name}"
@property
@override
def tokenizer(self) -> AnthropicEstimatingTokenizer:
return self._estimating_tokenizer
@policy(
[
retry(
exceptions=(
APIConnectionError,
APITimeoutError,
RateLimitError,
APIResponseValidationError,
)
),
retry(InternalServerError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
with self.logger.scope(f"Anthropic LLM Request ({self.schema.__name__})"):
return await self._do_generate(prompt, hints)
async def _do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
anthropic_api_arguments = {k: v for k, v in hints.items() if k in self.supported_hints}
t_start = time.time()
try:
response = await self._client.messages.create(
messages=[{"role": "user", "content": prompt}],
model=self.model_name,
max_tokens=4096,
**anthropic_api_arguments,
)
except RateLimitError:
self.logger.error(
(
"Anthropic API rate limit exceeded. Possible reasons:\n"
"1. Your account may have insufficient API credits.\n"
"2. You may be using a free-tier account with limited request capacity.\n"
"3. You might have exceeded the requests-per-minute limit for your account.\n\n"
"Recommended actions:\n"
"- Check your Anthropic account balance and billing status.\n"
"- Review your API usage limits in Anthropic's dashboard.\n"
"- For more details on rate limits and usage tiers, visit:\n"
" https://docs.anthropic.com/claude/reference/rate-limits \n"
),
)
raise
t_end = time.time()
if response.usage:
self.logger.trace(response.usage.model_dump_json(indent=2))
raw_content = response.content[0].text
try:
json_content = normalize_json_output(raw_content)
json_object = jsonfinder.only_json(json_content)[2]
except Exception:
self.logger.error(
f"Failed to extract JSON returned by {self.model_name}:\n{raw_content}"
)
raise
try:
model_content = self.schema.model_validate(json_object)
await record_llm_metrics(
self.meter,
self.model_name,
schema_name=self.schema.__name__,
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
)
return SchematicGenerationResult(
content=model_content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
),
),
)
except ValidationError:
self.logger.error(
f"JSON content returned by {self.model_name} does not match expected schema:\n{raw_content}"
)
raise
class Claude_Sonnet_3_5(AnthropicAISchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="claude-3-5-sonnet-20241022",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 200 * 1024
class Claude_Sonnet_4(AnthropicAISchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="claude-sonnet-4-20250514",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 200 * 1024
class Claude_Opus_4_1(AnthropicAISchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="claude-opus-4-1-20250805",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 200 * 1024
class AnthropicService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
if not os.environ.get("ANTHROPIC_API_KEY"):
return """\
You're using the Anthropic NLP service, but ANTHROPIC_API_KEY is not set.
Please set ANTHROPIC_API_KEY in your environment before running Parlant.
"""
return None
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
self.logger = logger
self._tracer = tracer
self._meter = meter
self._health_reporter = health_reporter
self.logger.info("Initialized AnthropicService")
@property
@override
def supports_streaming(self) -> bool:
return False
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
raise NotImplementedError("Streaming is not supported. Check supports_streaming first.")
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> AnthropicAISchematicGenerator[T]:
if (
t == JourneyBacktrackNodeSelectionSchema
or t == DisambiguationGuidelineMatchesSchema
or t == CannedResponseSelectionSchema
):
return Claude_Opus_4_1[t](self.logger, self._tracer, self._meter, self._health_reporter) # type: ignore
return Claude_Sonnet_4[t](self.logger, self._tracer, self._meter, self._health_reporter) # type: ignore
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
return JinaAIEmbedder(self.logger, self._tracer, self._meter, self._health_reporter)
@override
async def get_moderation_service(self) -> ModerationService:
return NoModeration()
+256
View File
@@ -0,0 +1,256 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import time
from anthropic import (
AsyncAnthropicBedrock,
APIConnectionError,
APIResponseValidationError,
APITimeoutError,
InternalServerError,
RateLimitError,
) # type: ignore
from pydantic import ValidationError
from typing import Any, Mapping
from typing_extensions import override
import jsonfinder # type: ignore
import os
import tiktoken
from parlant.adapters.nlp.common import normalize_json_output, record_llm_metrics
from parlant.adapters.nlp.hugging_face import JinaAIEmbedder
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.tracer import Tracer
from parlant.core.meter import Meter
from parlant.core.nlp.embedding import Embedder
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.loggers import Logger
from parlant.core.nlp.moderation import ModerationService, NoModeration
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.service import (
EmbedderHints,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.health import HealthReporter
class AnthropicBedrockEstimatingTokenizer(EstimatingTokenizer):
def __init__(self) -> None:
self.encoding = tiktoken.encoding_for_model("gpt-4o-2024-08-06")
@override
async def estimate_token_count(self, prompt: str) -> int:
tokens = self.encoding.encode(prompt)
return int(len(tokens) * 1.15)
class AnthropicBedrockAISchematicGenerator(BaseSchematicGenerator[T]):
supported_hints = ["temperature"]
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._client = AsyncAnthropicBedrock(
aws_access_key=os.environ["AWS_ACCESS_KEY_ID"],
aws_secret_key=os.environ["AWS_SECRET_ACCESS_KEY"],
aws_region=os.environ["AWS_REGION"],
aws_session_token=os.environ.get("AWS_SESSION_TOKEN", None),
)
self._estimating_tokenizer = AnthropicBedrockEstimatingTokenizer()
@property
@override
def id(self) -> str:
return f"bedrock/{self.model_name}"
@property
@override
def tokenizer(self) -> AnthropicBedrockEstimatingTokenizer:
return self._estimating_tokenizer
@policy(
[
retry(
exceptions=(
APIConnectionError,
APITimeoutError,
RateLimitError,
APIResponseValidationError,
)
),
retry(InternalServerError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
with self.logger.scope(f"AWS LLM Request ({self.schema.__name__})"):
return await self._do_generate(prompt, hints)
async def _do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
anthropic_api_arguments = {k: v for k, v in hints.items() if k in self.supported_hints}
t_start = time.time()
try:
response = await self._client.messages.create(
messages=[{"role": "user", "content": prompt}],
model=self.model_name,
max_tokens=4096,
**anthropic_api_arguments,
)
except RateLimitError:
self.logger.error(
"AWS Bedrock API rate limit exceeded. Possible reasons:\n"
"1. Your account may have insufficient API credits.\n"
"2. You may be using a free-tier account with limited request capacity.\n"
"3. You might have exceeded the requests-per-minute limit for your account.\n\n"
"Recommended actions:\n"
"- Check your AWS Bedrock account balance and billing status.\n"
"- Review your API usage limits in AWS Bedrock's dashboard.\n"
"- For more details on rate limits and usage tiers, visit:\n"
" https://us-east-1.console.aws.amazon.com/servicequotas/home/services/bedrock/quotas",
)
raise
t_end = time.time()
raw_content = response.content[0].text
try:
json_content = normalize_json_output(raw_content)
json_object = jsonfinder.only_json(json_content)[2]
except Exception:
self.logger.error(
f"Failed to extract JSON returned by {self.model_name}:\n{raw_content}"
)
raise
try:
model_content = self.schema.model_validate(json_object)
await record_llm_metrics(
self.meter,
self.model_name,
schema_name=self.schema.__name__,
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
)
return SchematicGenerationResult(
content=model_content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
),
),
)
except ValidationError:
self.logger.error(
f"JSON content returned by {self.model_name} does not match expected schema:\n{raw_content}"
)
raise
class Claude_Sonnet_3_5(AnthropicBedrockAISchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="anthropic.claude-3-5-sonnet-20240620-v1:0",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@override
@property
def max_tokens(self) -> int:
return 200 * 1024
class BedrockService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
if not os.environ.get("ANTHROPIC_API_KEY"):
return """\
You're using the AWS Bedrock NLP service, but some environment variables are missing.
Please consider setting the following your environment before running Parlant.
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
- AWS_REGION
- AWS_SESSION_TOKEN
"""
return None
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
self._logger = logger
self._tracer = tracer
self._meter = meter
self._health_reporter = health_reporter
@property
@override
def supports_streaming(self) -> bool:
return False
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
raise NotImplementedError("Streaming is not supported. Check supports_streaming first.")
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> AnthropicBedrockAISchematicGenerator[T]:
return Claude_Sonnet_3_5[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
return JinaAIEmbedder(self._logger, self._tracer, self._meter, self._health_reporter)
@override
async def get_moderation_service(self) -> ModerationService:
return NoModeration()
+672
View File
@@ -0,0 +1,672 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import time
from openai import (
AsyncAzureOpenAI,
APIConnectionError,
APIResponseValidationError,
APITimeoutError,
InternalServerError,
RateLimitError,
) # type: ignore
from azure.identity.aio import DefaultAzureCredential # type: ignore
from typing import Any, Mapping
from typing_extensions import override
import json
import jsonfinder # type: ignore
import os
from pydantic import ValidationError
import tiktoken
from parlant.adapters.nlp.common import normalize_json_output, record_llm_metrics
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.loggers import Logger
from parlant.core.tracer import Tracer
from parlant.core.meter import Meter
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.nlp.service import (
EmbedderHints,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.embedding import BaseEmbedder, Embedder, EmbeddingResult
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.nlp.moderation import ModerationService, NoModeration
from parlant.core.health import HealthReporter
class AzureEstimatingTokenizer(EstimatingTokenizer):
def __init__(self, model_name: str) -> None:
self.model_name = model_name
self.encoding = tiktoken.encoding_for_model(model_name)
async def estimate_token_count(self, prompt: str) -> int:
tokens = self.encoding.encode(prompt)
return len(tokens)
class AzureSchematicGenerator(BaseSchematicGenerator[T]):
supported_azure_params = ["temperature", "logit_bias", "max_tokens"]
supported_hints = supported_azure_params + ["strict"]
unsupported_params_by_model: dict[str, list[str]] = {
"gpt-5": ["temperature"],
}
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
client: AsyncAzureOpenAI,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._client = client
self._tokenizer = AzureEstimatingTokenizer(model_name=self.model_name)
@property
def id(self) -> str:
return f"azure/{self.model_name}"
@property
def tokenizer(self) -> AzureEstimatingTokenizer:
return self._tokenizer
def _list_arguments(self, hints: Mapping[str, Any]) -> Mapping[str, Any]:
exclude_params = [
k
for k in self.supported_azure_params
for prefix, excluded in self.unsupported_params_by_model.items()
if self.model_name.startswith(prefix) and k in excluded
]
return {
k: v
for k, v in hints.items()
if k in self.supported_azure_params and k not in exclude_params
}
@policy(
[
retry(
exceptions=(
APIConnectionError,
APITimeoutError,
RateLimitError,
APIResponseValidationError,
)
),
retry(InternalServerError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
with self.logger.scope(f"Azure LLM Request ({self.schema.__name__})"):
return await self._do_generate(prompt, hints)
async def _do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
azure_api_arguments = self._list_arguments(hints)
if hints.get("strict", False):
t_start = time.time()
try:
response = await self._client.beta.chat.completions.parse(
messages=[{"role": "user", "content": prompt}],
model=self.model_name,
response_format=self.schema,
**azure_api_arguments,
)
except RateLimitError:
self.logger.error(
"Azure API rate limit exceeded. Possible reasons:\n"
"1. Your account may have insufficient API credits.\n"
"2. You may be using a free-tier account with limited request capacity.\n"
"3. You might have exceeded the requests-per-minute limit for your account.\n\n"
"Recommended actions:\n"
"- Check your Azure account balance and billing status.\n"
"- Review your API usage limits in Azure's dashboard.\n"
"- For more details on rate limits and usage tiers, visit:\n"
" https://learn.microsoft.com/en-us/azure/ai-services/openai/quotas-limits\n",
)
raise
t_end = time.time()
if response.usage:
self.logger.trace(response.usage.model_dump_json(indent=2))
parsed_object = response.choices[0].message.parsed
assert parsed_object
assert response.usage
await record_llm_metrics(
self.meter,
self.model_name,
schema_name=self.schema.__name__,
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
cached_input_tokens=response.usage.prompt_tokens_details.cached_tokens or 0
if response.usage.prompt_tokens_details
else 0,
)
return SchematicGenerationResult[T](
content=parsed_object,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
extra=(
{
"cached_input_tokens": response.usage.prompt_tokens_details.cached_tokens
or 0
}
if response.usage.prompt_tokens_details
else {}
),
),
),
)
else:
t_start = time.time()
try:
response = await self._client.chat.completions.create(
messages=[{"role": "user", "content": prompt}],
model=self.model_name,
response_format={"type": "json_object"},
**azure_api_arguments,
)
except RateLimitError:
self.logger.error(
"Azure API rate limit exceeded. Possible reasons:\n"
"1. Your account may have insufficient API credits.\n"
"2. You may be using a free-tier account with limited request capacity.\n"
"3. You might have exceeded the requests-per-minute limit for your account.\n\n"
"Recommended actions:\n"
"- Check your Azure account balance and billing status.\n"
"- Review your API usage limits in Azure's dashboard.\n"
"- For more details on rate limits and usage tiers, visit:\n"
" https://learn.microsoft.com/en-us/azure/ai-services/openai/quotas-limits\n",
)
raise
t_end = time.time()
if response.usage:
self.logger.trace(response.usage.model_dump_json(indent=2))
raw_content = response.choices[0].message.content or "{}"
try:
json_content = json.loads(normalize_json_output(raw_content))
except json.JSONDecodeError:
self.logger.warning(f"Invalid JSON returned by {self.model_name}:\n{raw_content})")
json_content = jsonfinder.only_json(raw_content)[2]
self.logger.warning("Found JSON content within model response; continuing...")
try:
content = self.schema.model_validate(json_content)
assert response.usage
return SchematicGenerationResult(
content=content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
extra=(
{
"cached_input_tokens": response.usage.prompt_tokens_details.cached_tokens
or 0
}
if response.usage.prompt_tokens_details
else {}
),
),
),
)
except ValidationError:
self.logger.error(
f"JSON content returned by {self.model_name} does not match expected schema:\n{raw_content}"
)
raise
def create_azure_client() -> AsyncAzureOpenAI:
"""Create an Azure OpenAI client with appropriate authentication."""
azure_endpoint = os.environ["AZURE_ENDPOINT"]
# Check if API key is provided (backward compatibility)
if os.environ.get("AZURE_API_KEY"):
return AsyncAzureOpenAI(
api_key=os.environ["AZURE_API_KEY"],
azure_endpoint=azure_endpoint,
api_version=os.environ.get("AZURE_API_VERSION", "2024-08-01-preview"),
)
else:
# Use Azure AD authentication
try:
credential = DefaultAzureCredential()
async def token_provider() -> str:
"""Token provider that requests tokens with the correct scope for Azure OpenAI."""
try:
token = await credential.get_token(
"https://cognitiveservices.azure.com/.default"
)
return str(token.token)
except Exception as e:
raise RuntimeError(
f"Failed to get Azure AD token: {e}\n\n"
"Please ensure you are authenticated with Azure AD using one of:\n"
"1. Azure CLI: `az login`\n"
"2. Service Principal environment variables:\n"
" - AZURE_CLIENT_ID\n"
" - AZURE_CLIENT_SECRET\n"
" - AZURE_TENANT_ID\n"
"3. Managed Identity (if running on Azure)\n\n"
"For more details, see: https://docs.microsoft.com/en-us/python/api/overview/azure/identity-readme"
) from e
return AsyncAzureOpenAI(
azure_ad_token_provider=token_provider,
azure_endpoint=azure_endpoint,
api_version=os.environ.get("AZURE_API_VERSION", "2024-08-01-preview"),
)
except Exception as e:
raise RuntimeError(
f"Failed to initialize Azure AD authentication: {e}\n\n"
"Please ensure you are authenticated with Azure AD using one of:\n"
"1. Azure CLI: `az login`\n"
"2. Service Principal environment variables:\n"
" - AZURE_CLIENT_ID\n"
" - AZURE_CLIENT_SECRET\n"
" - AZURE_TENANT_ID\n"
"3. Managed Identity (if running on Azure)\n\n"
"For more details, see: https://docs.microsoft.com/en-us/python/api/overview/azure/identity-readme"
) from e
class CustomAzureSchematicGenerator(AzureSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
_client = create_azure_client()
super().__init__(
model_name=os.environ["AZURE_GENERATIVE_MODEL_NAME"],
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
client=_client,
)
@property
def max_tokens(self) -> int:
return int(os.environ.get("AZURE_GENERATIVE_MODEL_WINDOW", 4096))
class GPT_4o(AzureSchematicGenerator[T]):
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
_client = create_azure_client()
super().__init__(
model_name="gpt-4o", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, client=_client
)
@property
def max_tokens(self) -> int:
return 128 * 1024
class GPT_4o_Mini(AzureSchematicGenerator[T]):
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
_client = create_azure_client()
super().__init__(
model_name="gpt-4o-mini", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, client=_client
)
self._token_estimator = AzureEstimatingTokenizer(model_name=self.model_name)
@property
def max_tokens(self) -> int:
return 128 * 1024
class AzureEmbedder(BaseEmbedder):
supported_arguments = ["dimensions"]
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
client: AsyncAzureOpenAI,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._client = client
self._tokenizer = AzureEstimatingTokenizer(model_name=self.model_name)
@property
@override
def id(self) -> str:
return f"azure/{self.model_name}"
@property
@override
def tokenizer(self) -> AzureEstimatingTokenizer:
return self._tokenizer
@override
async def do_embed(
self,
texts: list[str],
hints: Mapping[str, Any] = {},
) -> EmbeddingResult:
filtered_hints = {k: v for k, v in hints.items() if k in self.supported_arguments}
try:
response = await self._client.embeddings.create(
model=self.model_name,
input=texts,
**filtered_hints,
)
except RateLimitError:
self.logger.error(
"Azure API rate limit exceeded. Possible reasons:\n"
"1. Your account may have insufficient API credits.\n"
"2. You may be using a free-tier account with limited request capacity.\n"
"3. You might have exceeded the requests-per-minute limit for your account.\n\n"
"Recommended actions:\n"
"- Check your Azure account balance and billing status.\n"
"- Review your API usage limits in Azure's dashboard.\n"
"- For more details on rate limits and usage tiers, visit:\n"
" https://learn.microsoft.com/en-us/azure/ai-services/openai/quotas-limits\n",
)
raise
vectors = [data_point.embedding for data_point in response.data]
return EmbeddingResult(vectors=vectors)
class CustomAzureEmbedder(AzureEmbedder):
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
_client = create_azure_client()
super().__init__(
model_name=os.environ["AZURE_EMBEDDING_MODEL_NAME"],
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
client=_client,
)
@property
@override
def max_tokens(self) -> int:
return int(os.environ["AZURE_EMBEDDING_MODEL_WINDOW"])
@property
def dimensions(self) -> int:
return int(os.environ["AZURE_EMBEDDING_MODEL_DIMS"])
class AzureTextEmbedding3Large(AzureEmbedder):
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
_client = create_azure_client()
super().__init__(
model_name="text-embedding-3-large",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
client=_client,
)
@property
@override
def max_tokens(self) -> int:
return 8192
@property
def dimensions(self) -> int:
return 3072
class AzureTextEmbedding3Small(AzureEmbedder):
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
_client = create_azure_client()
super().__init__(
model_name="text-embedding-3-small",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
client=_client,
)
@property
def max_tokens(self) -> int:
return 8192
@property
def dimensions(self) -> int:
return 1536
class AzureService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
if not os.environ.get("AZURE_ENDPOINT"):
return """\
You're using the Azure NLP service, but AZURE_ENDPOINT is not set.
Please set AZURE_ENDPOINT in your environment before running Parlant.
Required environment variables:
- AZURE_ENDPOINT
Authentication options (choose one):
1. Azure AD (recommended):
- Ensure you're authenticated via Azure CLI: `az login`
- Or set up managed identity/service principal authentication
2. API Key (legacy):
- AZURE_API_KEY
You can also set any specific models you'd like to use, using a few more variables:
- AZURE_GENERATIVE_MODEL_NAME (e.g., gpt-4o)
- AZURE_GENERATIVE_MODEL_WINDOW (size of the generative model's context window)
- AZURE_EMBEDDING_MODEL_NAME (e.g., text-embedding-3-large)
- AZURE_EMBEDDING_MODEL_DIMS (dimensions of the embedding model)
- AZURE_EMBEDDING_MODEL_WINDOW (size of of the embedding model's context window)
For Azure AD authentication, ensure your identity has the "Cognitive Services OpenAI User" role
on the Azure OpenAI resource.
"""
# Check authentication method
has_api_key = bool(os.environ.get("AZURE_API_KEY"))
if has_api_key:
# API key authentication is configured
return None
# Check Azure AD authentication
try:
from azure.identity import DefaultAzureCredential # type: ignore
credential = DefaultAzureCredential()
# Try to get a token to verify authentication works
import asyncio
async def test_auth() -> bool:
try:
token = credential.get_token("https://cognitiveservices.azure.com/.default")
return token is not None
except Exception:
return False
# Run the async test
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# If we're already in an async context, we can't test synchronously
# Just check if we can create the credential
return None
else:
auth_works = loop.run_until_complete(test_auth())
if auth_works:
return None
except RuntimeError:
# No event loop, create a new one
auth_works = asyncio.run(test_auth())
if auth_works:
return None
except Exception:
pass
# If we get here, neither authentication method is working
return """\
Azure authentication is not properly configured.
Please choose one of the following authentication methods:
1. API Key Authentication (Legacy):
Set the AZURE_API_KEY environment variable with your Azure OpenAI API key.
2. Azure AD Authentication (Recommended):
Ensure you're authenticated using one of these methods:
a) Azure CLI (for development):
Run: az login
b) Service Principal (for production):
Set these environment variables:
- AZURE_CLIENT_ID
- AZURE_CLIENT_SECRET
- AZURE_TENANT_ID
c) Managed Identity (if running on Azure):
Ensure your Azure resource has managed identity enabled
d) Environment Credential:
Set these environment variables:
- AZURE_CLIENT_ID
- AZURE_CLIENT_SECRET
- AZURE_TENANT_ID
e) Workload Identity (for Kubernetes):
Set these environment variables:
- AZURE_CLIENT_ID
- AZURE_TENANT_ID
- AZURE_FEDERATED_TOKEN_FILE
Important: For Azure AD authentication, ensure your identity has the
"Cognitive Services OpenAI User" role on the Azure OpenAI resource.
For more details on Azure AD authentication options, see:
https://docs.microsoft.com/en-us/python/api/overview/azure/identity-readme
"""
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
self.logger = logger
self._tracer = tracer
self._meter = meter
self._health_reporter = health_reporter
@property
@override
def supports_streaming(self) -> bool:
return False
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
raise NotImplementedError("Streaming is not supported. Check supports_streaming first.")
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> AzureSchematicGenerator[T]:
if os.environ.get("AZURE_GENERATIVE_MODEL_NAME"):
return CustomAzureSchematicGenerator[t]( # type: ignore
logger=self.logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
return GPT_4o[t](self.logger, self._tracer, self._meter, self._health_reporter) # type: ignore
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
if os.environ.get("AZURE_EMBEDDING_MODEL_NAME"):
return CustomAzureEmbedder(self.logger, self._tracer, self._meter, self._health_reporter)
return AzureTextEmbedding3Large(self.logger, self._tracer, self._meter, self._health_reporter)
async def get_moderation_service(self) -> ModerationService:
return NoModeration()
@@ -0,0 +1,281 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import time
from pydantic import ValidationError
from cerebras.cloud.sdk import AsyncCerebras
from cerebras.cloud.sdk import (
RateLimitError,
APIConnectionError,
APITimeoutError,
InternalServerError,
)
from typing import Any, Mapping
from typing_extensions import override
import jsonfinder # type: ignore
import os
import tiktoken
from parlant.adapters.nlp.common import normalize_json_output, record_llm_metrics
from parlant.adapters.nlp.hugging_face import JinaAIEmbedder
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.tracer import Tracer
from parlant.core.meter import Meter
from parlant.core.nlp.embedding import Embedder
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.loggers import Logger
from parlant.core.nlp.moderation import ModerationService, NoModeration
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.service import (
EmbedderHints,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.health import HealthReporter
class LlamaEstimatingTokenizer(EstimatingTokenizer):
def __init__(self) -> None:
self.encoding = tiktoken.encoding_for_model("gpt-4o-2024-08-06")
@override
async def estimate_token_count(self, prompt: str) -> int:
tokens = self.encoding.encode(prompt)
return len(tokens) + 36
class CerebrasSchematicGenerator(BaseSchematicGenerator[T]):
supported_hints = ["temperature"]
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._client = AsyncCerebras(api_key=os.environ.get("CEREBRAS_API_KEY"))
@policy(
[
retry(
exceptions=(
APIConnectionError,
APITimeoutError,
RateLimitError,
),
),
retry(InternalServerError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
with self.logger.scope(f"Cerebras LLM Request ({self.schema.__name__})"):
return await self._do_generate(prompt, hints)
async def _do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
cerebras_api_arguments = {k: v for k, v in hints.items() if k in self.supported_hints}
t_start = time.time()
try:
response = await self._client.chat.completions.create(
messages=[{"role": "user", "content": prompt}],
model=self.model_name,
response_format={
"type": "json_schema",
"json_schema": {
"schema": self.schema.model_json_schema(),
"name": self.schema.__name__,
"strict": True,
},
},
**cerebras_api_arguments,
)
except RateLimitError:
self.logger.error(
"Cerebras API rate limit exceeded.\n"
"Your account may have reached the maximum number of requests allowed per minute for the tier you are using.\n"
"Please contact with Cerebras support for more information."
)
raise
t_end = time.time()
if response.usage: # type: ignore
self.logger.trace(response.usage.model_dump_json(indent=2)) # type: ignore
raw_content = response.choices[0].message.content or "{}" # type: ignore
try:
json_content = normalize_json_output(raw_content)
json_object = jsonfinder.only_json(json_content)[2]
except Exception:
self.logger.error(
f"Failed to extract JSON returned by {self.model_name}:\n{raw_content}"
)
raise
try:
model_content = self.schema.model_validate(json_object)
await record_llm_metrics(
self.meter,
self.model_name,
input_tokens=response.usage.prompt_tokens, # type: ignore
output_tokens=response.usage.completion_tokens, # type: ignore
)
return SchematicGenerationResult(
content=model_content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=response.usage.prompt_tokens, # type: ignore
output_tokens=response.usage.completion_tokens, # type: ignore
extra={},
),
),
)
except ValidationError:
self.logger.error(
f"JSON content returned by {self.model_name} does not match expected schema:\n{raw_content}"
)
raise
class Llama3_3_8B(CerebrasSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="llama3.1-8b",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
self._estimating_tokenizer = LlamaEstimatingTokenizer()
@property
@override
def id(self) -> str:
return self.model_name
@property
@override
def max_tokens(self) -> int:
return 8192
@property
@override
def tokenizer(self) -> LlamaEstimatingTokenizer:
return self._estimating_tokenizer
class Llama3_3_70B(CerebrasSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="llama3.3-70b",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
self._estimating_tokenizer = LlamaEstimatingTokenizer()
@property
@override
def id(self) -> str:
return self.model_name
@property
@override
def tokenizer(self) -> LlamaEstimatingTokenizer:
return self._estimating_tokenizer
@property
@override
def max_tokens(self) -> int:
return 32 * 1024
class CerebrasService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
if not os.environ.get("CEREBRAS_API_KEY"):
return """\
You're using the OpenAI NLP service, but CEREBRAS_API_KEY is not set.
Please set CEREBRAS_API_KEY in your environment before running Parlant.
"""
return None
def __init__(
self,
logger: Logger,
tracer: Tracer,
meter: Meter,
health_reporter: HealthReporter,
) -> None:
self.logger = logger
self._tracer = tracer
self.meter = meter
self._health_reporter = health_reporter
self.logger.info("Initialized CerebrasService")
@property
@override
def supports_streaming(self) -> bool:
return False
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
raise NotImplementedError("Streaming is not supported. Check supports_streaming first.")
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> CerebrasSchematicGenerator[T]:
return Llama3_3_70B[t](self.logger, self._tracer, self.meter, self._health_reporter) # type: ignore
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
return JinaAIEmbedder(self.logger, self._tracer, self.meter, self._health_reporter)
@override
async def get_moderation_service(self) -> ModerationService:
return NoModeration()
+83
View File
@@ -0,0 +1,83 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from parlant.core.meter import Counter, Meter
def normalize_json_output(raw_output: str) -> str:
json_start = raw_output.find("```json")
if json_start != -1:
json_start = json_start + 7
else:
json_start = 0
json_end = raw_output[json_start:].rfind("```")
if json_end == -1:
json_end = len(raw_output[json_start:])
return raw_output[json_start : json_start + json_end].strip()
_INPUT_TOKENS_COUNTER: Counter
_OUTPUT_TOKENS_COUNTER: Counter
_CACHED_TOKENS_COUNTER: Counter
_COUNTERS_INITIALIZED = False
async def record_llm_metrics(
meter: Meter,
model_name: str,
schema_name: str,
input_tokens: int,
output_tokens: int,
cached_input_tokens: int = 0,
) -> None:
global _COUNTERS_INITIALIZED
global _INPUT_TOKENS_COUNTER
global _OUTPUT_TOKENS_COUNTER
global _CACHED_TOKENS_COUNTER
if not _COUNTERS_INITIALIZED:
_INPUT_TOKENS_COUNTER = meter.create_counter(
name="input_tokens",
description="Number of input tokens sent to a LLM model",
)
_OUTPUT_TOKENS_COUNTER = meter.create_counter(
name="output_tokens",
description="Number of output tokens received from a LLM model",
)
_CACHED_TOKENS_COUNTER = meter.create_counter(
name="cached_input_tokens",
description="Number of input tokens served from cache for a LLM model",
)
_COUNTERS_INITIALIZED = True
await _INPUT_TOKENS_COUNTER.increment(
input_tokens,
{"model_name": model_name, "schema_name": schema_name},
)
await _OUTPUT_TOKENS_COUNTER.increment(
output_tokens,
{"model_name": model_name, "schema_name": schema_name},
)
await _CACHED_TOKENS_COUNTER.increment(
cached_input_tokens,
{"model_name": model_name, "schema_name": schema_name},
)
@@ -0,0 +1,263 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import time
from openai import (
APIConnectionError,
APIResponseValidationError,
APITimeoutError,
AsyncClient,
ConflictError,
InternalServerError,
RateLimitError,
)
from typing import Any, Mapping
from typing_extensions import override
import json
import jsonfinder # type: ignore
import os
from pydantic import ValidationError
import tiktoken
from parlant.adapters.nlp.common import normalize_json_output, record_llm_metrics
from parlant.adapters.nlp.hugging_face import JinaAIEmbedder
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.loggers import Logger
from parlant.core.tracer import Tracer
from parlant.core.meter import Meter
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.nlp.service import (
EmbedderHints,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.embedding import Embedder
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.nlp.moderation import (
ModerationService,
NoModeration,
)
from parlant.core.health import HealthReporter
class DeepSeekEstimatingTokenizer(EstimatingTokenizer):
def __init__(self, model_name: str) -> None:
self.model_name = model_name
self.encoding = tiktoken.encoding_for_model("gpt-4o-2024-08-06")
@override
async def estimate_token_count(self, prompt: str) -> int:
tokens = self.encoding.encode(prompt)
return len(tokens)
class DeepSeekSchematicGenerator(BaseSchematicGenerator[T]):
supported_deepseek_params = ["temperature", "logit_bias", "max_tokens"]
supported_hints = supported_deepseek_params + ["strict"]
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._client = AsyncClient(
base_url="https://api.deepseek.com",
api_key=os.environ["DEEPSEEK_API_KEY"],
)
self._tokenizer = DeepSeekEstimatingTokenizer(model_name=self.model_name)
@property
@override
def id(self) -> str:
return f"deepseek/{self.model_name}"
@property
@override
def tokenizer(self) -> DeepSeekEstimatingTokenizer:
return self._tokenizer
@policy(
[
retry(
exceptions=(
APIConnectionError,
APITimeoutError,
ConflictError,
RateLimitError,
APIResponseValidationError,
),
),
retry(InternalServerError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
with self.logger.scope(f"DeepSeek LLM Request ({self.schema.__name__})"):
return await self._do_generate(prompt, hints)
async def _do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
deepseek_api_arguments = {
k: v for k, v in hints.items() if k in self.supported_deepseek_params
}
t_start = time.time()
response = await self._client.chat.completions.create(
messages=[{"role": "user", "content": prompt}],
model=self.model_name,
max_tokens=8192,
response_format={"type": "json_object"},
**deepseek_api_arguments,
)
t_end = time.time()
if response.usage:
self.logger.trace(response.usage.model_dump_json(indent=2))
raw_content = response.choices[0].message.content or "{}"
try:
json_content = json.loads(normalize_json_output(raw_content))
except json.JSONDecodeError:
self.logger.warning(f"Invalid JSON returned by {self.model_name}:\n{raw_content})")
json_content = jsonfinder.only_json(raw_content)[2]
self.logger.warning("Found JSON content within model response; continuing...")
try:
content = self.schema.model_validate(json_content)
assert response.usage
await record_llm_metrics(
self.meter,
self.model_name,
schema_name=self.schema.__name__,
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
cached_input_tokens=getattr(
response,
"usage.prompt_cache_hit_tokens",
0,
),
)
return SchematicGenerationResult(
content=content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
extra={
"cached_input_tokens": getattr(
response,
"usage.prompt_cache_hit_tokens",
0,
)
},
),
),
)
except ValidationError:
self.logger.error(
f"JSON content returned by {self.model_name} does not match expected schema:\n{raw_content}"
)
raise
class DeepSeek_Chat(DeepSeekSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="deepseek-chat", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class DeepSeekService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
if not os.environ.get("DEEPSEEK_API_KEY"):
return """\
You're using the DeepSeek NLP service, but DEEPSEEK_API_KEY is not set.
Please set DEEPSEEK_API_KEY in your environment before running Parlant.
"""
return None
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
self._logger = logger
self._tracer = tracer
self._meter = meter
self._health_reporter = health_reporter
self._logger.info("Initialized DeepSeekService")
@property
@override
def supports_streaming(self) -> bool:
return False
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
raise NotImplementedError("Streaming is not supported. Check supports_streaming first.")
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> DeepSeekSchematicGenerator[T]:
return DeepSeek_Chat[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
return JinaAIEmbedder(self._logger, self._tracer, self._meter, self._health_reporter)
@override
async def get_moderation_service(self) -> ModerationService:
return NoModeration()
+774
View File
@@ -0,0 +1,774 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from pprint import pformat
import re
import time
from typing import Any, AsyncIterator, Callable, Mapping, TypeAlias, cast
from httpx import AsyncClient
import httpx
from typing_extensions import Literal, override
import json
import jsonfinder # type: ignore
import os
from pydantic import ValidationError
import tiktoken
from parlant.adapters.nlp.common import normalize_json_output, record_llm_metrics
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.loggers import Logger
from parlant.core.meter import Meter
from parlant.core.services.indexing.common import ProgressReport
from parlant.core.services.indexing.indexer import IndexRequest, Indexer
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.nlp.service import (
EmbedderHints,
ModelSize,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.embedding import BaseEmbedder, Embedder, EmbeddingResult
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
BaseStreamingTextGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.nlp.moderation import (
ModerationService,
NoModeration,
)
from parlant.core.tracer import Tracer
from parlant.core.version import VERSION
from parlant.core.health import HealthReporter
RATE_LIMIT_ERROR_MESSAGE = (
"Emcie API rate limit exceeded. Possible reasons:\n"
"1. Your account may have insufficient API credits.\n"
"2. You might have exceeded the requests-per-minute limit for your account.\n\n"
"Recommended actions:\n"
"- Check your Emcie account balance and billing status.\n"
"- Review your API usage limits in Emcie's dashboard.\n"
"- For more details on rate limits and usage tiers, visit:\n"
" https://docs.emcie.co\n"
)
GenerationModelTier: TypeAlias = Literal["jackal", "bison"]
EmbeddingModelTier: TypeAlias = Literal["jackal-embedding", "bison-embedding"]
ModelRole: TypeAlias = Literal["teacher", "student", "auto"]
BASE_URL = os.environ.get("EMCIE_API_URL", "https://api.emcie.co/inference")
# Pattern to detect word boundaries for chunking
# Matches after any whitespace character
_WORD_BOUNDARY_PATTERN = re.compile(r"(?<=\s)")
# Number of words to buffer before yielding a chunk
_WORDS_PER_CHUNK = 3
class EmcieEstimatingTokenizer(EstimatingTokenizer):
def __init__(self) -> None:
self.encoding = tiktoken.encoding_for_model("gpt-4.1")
@override
async def estimate_token_count(self, prompt: str) -> int:
tokens = self.encoding.encode(prompt)
return len(tokens)
class EmcieAPIError(Exception):
pass
class InsufficientCreditsError(EmcieAPIError):
pass
class RateLimitError(EmcieAPIError):
pass
class UnauthorizedError(EmcieAPIError):
pass
def _get_error_detail(response: httpx.Response) -> tuple[str, str]:
try:
error_message = (
response.json().get("detail", {}).get("error", {}).get("message", "Unknown error")
)
request_id = response.json().get("detail", {}).get("request_id", "N/A")
except Exception:
try:
error_message = response.text
except Exception:
error_message = "Unknown error (failed to parse error message)"
request_id = "N/A"
return error_message, request_id
class EmcieSchematicGenerator(BaseSchematicGenerator[T]):
supported_emcie_params = ["temperature"]
def __init__(self,
model_name: str,
model_role: ModelRole,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._model_role = model_role
self._tokenizer = EmcieEstimatingTokenizer()
@property
@override
def id(self) -> str:
return f"emcie/{self.model_name}"
@property
@override
def tokenizer(self) -> EmcieEstimatingTokenizer:
return self._tokenizer
@policy(
[
retry(exceptions=(RateLimitError)),
retry(EmcieAPIError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
with self.logger.scope(f"Emcie LLM Request ({self.schema.__name__})"):
return await self._do_generate(prompt, hints)
async def _do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
props = prompt.props
prompt = prompt.build()
else:
props = {}
try:
t_start = time.time()
timeout = httpx.Timeout(
connect=30.0,
read=120.0,
write=30.0,
pool=5.0,
)
async with AsyncClient(timeout=timeout) as client:
response = await client.post(
f"{BASE_URL}/v1/completions",
headers={
"Authorization": f"Bearer {os.environ['EMCIE_API_KEY']}",
"X-Parlant-Version": VERSION,
},
json={
"model_tier": self.model_name,
"model_role": self._model_role,
"prompt": prompt,
"schema_name": self.schema.__name__,
"hints": {
k: v for k, v in hints.items() if k in self.supported_emcie_params
},
"payload": props,
},
)
if response.is_error:
error_message, request_id = _get_error_detail(response)
if response.status_code == 429:
raise RateLimitError(
f"Emcie API rate limit exceeded: {error_message} (RID={request_id})"
)
elif response.status_code == 402:
raise InsufficientCreditsError(
f"Insufficient API credits for Emcie API: {error_message} (RID={request_id})"
)
elif response.status_code == 403:
raise UnauthorizedError(
f"Unauthorized access to Emcie API: {error_message} (RID={request_id})"
)
elif response.status_code >= 500:
raise EmcieAPIError(
f"Emcie API error: {response.status_code} {error_message} (RID={request_id})"
)
response.raise_for_status()
t_end = time.time()
except (InsufficientCreditsError, RateLimitError):
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise
except EmcieAPIError as e:
self.logger.error(f"Emcie API error occurred: {e}")
raise
except Exception as e:
self.logger.error(f"Unexpected error during Emcie API call: {e}")
raise
response_data = response.json()
usage = response_data["usage"]
cost = response_data["cost"]
self.logger.trace(f"Emcie usage data:\n{pformat({**usage, **cost})}")
raw_content = response_data["completion"]
try:
json_content = json.loads(normalize_json_output(raw_content))
except json.JSONDecodeError:
self.logger.warning(f"Invalid JSON returned by {self.model_name}:\n{raw_content})")
json_content = jsonfinder.only_json(raw_content)[2]
self.logger.warning("Found JSON content within model response; continuing...")
try:
content = self.schema.model_validate(json_content)
await record_llm_metrics(
self.meter,
self.model_name,
schema_name=self.schema.__name__,
input_tokens=int(usage["input_tokens"]),
output_tokens=int(usage["output_tokens"]),
cached_input_tokens=0,
)
return SchematicGenerationResult(
content=content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=int(usage["input_tokens"]),
output_tokens=int(usage["output_tokens"]),
extra={},
),
),
)
except ValidationError as e:
self.logger.error(
f"Error: {e.json(indent=2)}\nJSON content returned by {self.model_name} does not match expected schema:\n{raw_content}"
)
raise
class Jackal(EmcieSchematicGenerator[T]):
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
model_role: ModelRole,
) -> None:
super().__init__(
model_name="jackal",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
model_role=model_role,
)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class Bison(EmcieSchematicGenerator[T]):
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
model_role: ModelRole,
) -> None:
super().__init__(
model_name="bison",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
model_role=model_role,
)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
# ============================================================================
# Streaming Text Generators
# ============================================================================
class EmcieStreamingTextGenerator(BaseStreamingTextGenerator):
"""Streaming text generator using Emcie's streaming API.
Buffers tokens into word-sized chunks for smoother frontend rendering.
"""
supported_emcie_params = ["temperature"]
def __init__(self,
model_name: str,
model_role: ModelRole,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._model_role = model_role
self._tokenizer = EmcieEstimatingTokenizer()
@property
@override
def id(self) -> str:
return f"emcie-streaming/{self.model_name}"
@property
@override
def tokenizer(self) -> EmcieEstimatingTokenizer:
return self._tokenizer
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> tuple[AsyncIterator[str | None], Callable[[], UsageInfo]]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
# Track usage from the done event
usage_info: UsageInfo | None = None
async def chunk_generator() -> AsyncIterator[str | None]:
nonlocal usage_info
timeout = httpx.Timeout(
connect=30.0,
read=120.0,
write=30.0,
pool=5.0,
)
# Buffer for accumulating tokens into word-sized chunks
buffer = ""
async with AsyncClient(timeout=timeout) as client:
async with client.stream(
"POST",
f"{BASE_URL}/v1/completions",
headers={
"Authorization": f"Bearer {os.environ['EMCIE_API_KEY']}",
"X-Parlant-Version": VERSION,
},
json={
"model_tier": self.model_name,
"model_role": self._model_role,
"prompt": prompt,
"stream": True,
"hints": {
k: v for k, v in hints.items() if k in self.supported_emcie_params
},
},
) as response:
# Check status before iterating to catch auth/rate-limit errors early
if response.is_error:
await response.aread()
error_message, request_id = _get_error_detail(response)
if response.status_code == 429:
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise RateLimitError(
f"Emcie API rate limit exceeded: {error_message} (RID={request_id})"
)
elif response.status_code == 402:
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise InsufficientCreditsError(
f"Insufficient API credits for Emcie API: {error_message} (RID={request_id})"
)
elif response.status_code == 403:
raise UnauthorizedError(
f"Unauthorized access to Emcie API: {error_message} (RID={request_id})"
)
elif response.status_code >= 500:
raise EmcieAPIError(
f"Emcie API error: {response.status_code} {error_message} (RID={request_id})"
)
response.raise_for_status()
# Parse SSE events
event_type: str | None = None
async for line in response.aiter_lines():
if line.startswith("event: "):
event_type = line[7:]
elif line.startswith("data: ") and event_type:
data = json.loads(line[6:])
if event_type == "chunk":
text = data.get("text", "")
if text:
buffer += text
# Count word boundaries in buffer
boundaries = list(_WORD_BOUNDARY_PATTERN.finditer(buffer))
if len(boundaries) >= _WORDS_PER_CHUNK:
# Yield up to the last complete word boundary
last_boundary = boundaries[_WORDS_PER_CHUNK - 1]
chunk_text = buffer[: last_boundary.end()]
buffer = buffer[last_boundary.end() :]
yield chunk_text
elif event_type == "done":
usage = data.get("usage", {})
usage_info = UsageInfo(
input_tokens=int(usage.get("input_tokens", 0)),
output_tokens=int(usage.get("output_tokens", 0)),
extra={},
)
self.logger.trace(f"Emcie streaming usage data:\n{pformat(data)}")
# Yield any remaining content in the buffer
if buffer:
yield buffer
buffer = ""
elif event_type == "error":
error_msg = data.get("error", {}).get("message", "Unknown error")
raise EmcieAPIError(f"Emcie streaming error: {error_msg}")
# Record metrics if we have usage info
if usage_info is not None:
await record_llm_metrics(
self.meter,
self.model_name,
schema_name="streaming",
input_tokens=usage_info.input_tokens,
output_tokens=usage_info.output_tokens,
cached_input_tokens=0,
)
# Signal completion
yield None
def get_usage() -> UsageInfo:
if usage_info is None:
return UsageInfo(input_tokens=0, output_tokens=0, extra={})
return usage_info
return chunk_generator(), get_usage
class JackalStreaming(EmcieStreamingTextGenerator):
def __init__(self,
model_role: ModelRole,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(
model_name="jackal",
model_role=model_role,
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
class BisonStreaming(EmcieStreamingTextGenerator):
def __init__(self,
model_role: ModelRole,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(
model_name="bison",
model_role=model_role,
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
# ============================================================================
# Embedders
# ============================================================================
class EmcieEmbedder(BaseEmbedder):
supported_arguments = ["dimensions"]
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger, tracer, meter, model_name, health_reporter)
self._tokenizer = EmcieEstimatingTokenizer()
@property
@override
def id(self) -> str:
return f"emcie/{self.model_name}"
@property
@override
def tokenizer(self) -> EmcieEstimatingTokenizer:
return self._tokenizer
@policy(
[
retry(exceptions=(RateLimitError)),
retry(EmcieAPIError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_embed(
self,
texts: list[str],
hints: Mapping[str, Any] = {},
) -> EmbeddingResult:
try:
timeout = httpx.Timeout(
connect=5.0,
read=120.0,
write=30.0,
pool=5.0,
)
async with AsyncClient(timeout=timeout) as client:
response = await client.post(
f"{BASE_URL}/v1/embeddings",
headers={
"Authorization": f"Bearer {os.environ['EMCIE_API_KEY']}",
"X-Parlant-Version": VERSION,
},
json={
"model_tier": self.model_name,
"inputs": texts,
"hints": {k: v for k, v in hints.items() if k in self.supported_arguments},
},
)
if response.is_error:
error_message, request_id = _get_error_detail(response)
if response.status_code == 429:
raise RateLimitError(
f"Emcie API rate limit exceeded: {error_message} (RID={request_id})"
)
elif response.status_code == 402:
raise InsufficientCreditsError(
f"Insufficient API credits for Emcie API: {error_message} (RID={request_id})"
)
elif response.status_code == 403:
raise UnauthorizedError(
f"Unauthorized access to Emcie API: {error_message} (RID={request_id})"
)
elif response.status_code >= 500:
raise EmcieAPIError(
f"Emcie API error: {response.status_code} {error_message} (RID={request_id})"
)
response.raise_for_status()
except (RateLimitError, InsufficientCreditsError):
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise
except Exception as e:
self.logger.error(f"Unexpected error during Emcie API call: {e}")
raise
response_data = response.json()
vectors = [data_point["embedding"] for data_point in response_data["data"]]
return EmbeddingResult(vectors=vectors)
class BisonEmbedding(EmcieEmbedder):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="bison-embedding",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 8192
@property
def dimensions(self) -> int:
return 3072
class JackalEmbedding(EmcieEmbedder):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="jackal-embedding",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 8192
@property
def dimensions(self) -> int:
return 1536
class EmcieIndexer(Indexer):
@override
async def index(
self,
payload: Mapping[str, Mapping[str, IndexRequest]],
progress_report: ProgressReport,
) -> None:
return
class EmcieService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
if not os.environ.get("EMCIE_API_KEY"):
return """\
You're using Emcie's optimized NLP service, but EMCIE_API_KEY is not set.
Please set EMCIE_API_KEY in your environment before running Parlant.
For alternative providers, see https://parlant.io/docs/quickstart/installation.
Get an API key for Emcie by signing up at https://www.emcie.co."""
return None
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
model_tier: GenerationModelTier | None = None,
model_role: ModelRole | None = None,
) -> None:
self._logger = logger
self._meter = meter
self._health_reporter = health_reporter
self._tracer = tracer
self._model_tier = model_tier or os.environ.get("EMCIE_MODEL_TIER", "jackal")
self._model_role = model_role or os.environ.get("EMCIE_MODEL_ROLE", "auto")
assert self._model_tier in ("jackal", "bison"), "Invalid EMCIE_MODEL_TIER"
assert self._model_role in ("teacher", "student", "auto"), "Invalid EMCIE_MODEL_ROLE"
self._logger.info("Initialized EmcieService")
@property
@override
def supports_streaming(self) -> bool:
return True
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
match self._model_tier:
case "bison":
return BisonStreaming(
model_role=cast(ModelRole, self._model_role),
logger=self._logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
case _:
return JackalStreaming(
model_role=cast(ModelRole, self._model_role),
logger=self._logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> EmcieSchematicGenerator[T]:
match self._model_tier:
case "jackal":
return Jackal[t]( # type: ignore
model_role=cast(ModelRole, self._model_role),
logger=self._logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
case "bison":
return Bison[t]( # type: ignore
model_role=cast(ModelRole, self._model_role),
logger=self._logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
case _:
raise ValueError(f"Unsupported model tier: {self._model_tier}")
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
match hints.get("model_size", ModelSize.AUTO):
case ModelSize.AUTO | ModelSize.LARGE:
return BisonEmbedding(self._logger, self._tracer, self._meter, self._health_reporter)
case _:
return JackalEmbedding(self._logger, self._tracer, self._meter, self._health_reporter)
@override
async def get_moderation_service(self) -> ModerationService:
return NoModeration()
@@ -0,0 +1,452 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import time
import os
import tiktoken
import jsonfinder # type: ignore
from pydantic import ValidationError
from fireworks.client import AsyncFireworks # type: ignore
from typing import Any, Mapping
from typing_extensions import override
from fireworks.client.error import RateLimitError # type: ignore
from parlant.adapters.nlp.common import normalize_json_output, record_llm_metrics
from parlant.adapters.nlp.hugging_face import JinaAIEmbedder
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.tracer import Tracer
from parlant.core.meter import Meter
from parlant.core.nlp.embedding import Embedder
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.loggers import Logger
from parlant.core.nlp.moderation import ModerationService, NoModeration
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.service import (
EmbedderHints,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.health import HealthReporter
RATE_LIMIT_ERROR_MESSAGE = (
"Fireworks API rate limit exceeded. Possible reasons:\n"
"1. Your account may have insufficient API credits.\n"
"2. You may be using a free-tier account with limited request capacity.\n"
"3. You might have exceeded the requests-per-minute limit for your account.\n\n"
"Recommended actions:\n"
"- Check your Fireworks account balance and billing status.\n"
"- Review your API usage limits in Fireworks dashboard.\n"
"- For more details on rate limits and usage tiers, visit:\n"
" https://fireworks.ai/docs/guides/quotas_usage/rate-limits"
)
class FireworksEstimatingTokenizer(EstimatingTokenizer):
def __init__(self, model_name: str) -> None:
self.model_name = model_name
self.encoding = tiktoken.encoding_for_model("gpt-4o-2024-08-06")
@override
async def estimate_token_count(self, prompt: str) -> int:
tokens = self.encoding.encode(prompt)
return len(tokens) + 36
class FireworksSchematicGenerator(BaseSchematicGenerator[T]):
supported_hints = ["temperature", "max_tokens"]
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._client = AsyncFireworks(api_key=os.environ.get("FIREWORKS_API_KEY"))
self._tokenizer = FireworksEstimatingTokenizer(model_name=self.model_name)
@property
@override
def id(self) -> str:
return self.model_name
@property
@override
def tokenizer(self) -> FireworksEstimatingTokenizer:
return self._tokenizer
@policy(
[
retry(
exceptions=(
Exception, # Will handle specific Fireworks exceptions
),
max_exceptions=3,
wait_times=(1.0, 2.0, 4.0),
)
]
)
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
with self.logger.scope(f"Fireworks LLM Request ({self.schema.__name__})"):
return await self._do_generate(prompt, hints)
async def _do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
fireworks_api_arguments = {k: v for k, v in hints.items() if k in self.supported_hints}
t_start = time.time()
try:
response = self._client.chat.completions.create(
messages=[{"role": "user", "content": prompt}],
model=self.model_name,
response_format={
"type": "json_schema",
"json_schema": {
"schema": self.schema.model_json_schema(),
"name": self.schema.__name__,
"strict": True,
},
},
**fireworks_api_arguments,
)
except RateLimitError:
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise
t_end = time.time()
if response.usage: # type: ignore
self.logger.trace(f"Usage: {response.usage.model_dump_json(indent=2)}") # type: ignore
raw_content = response.choices[0].message.content or "{}" # type: ignore
try:
json_content = normalize_json_output(raw_content)
json_object = jsonfinder.only_json(json_content)[2]
except Exception:
self.logger.error(
f"Failed to extract JSON returned by {self.model_name}:\n{raw_content}"
)
raise
try:
model_content = self.schema.model_validate(json_object)
await record_llm_metrics(
self.meter,
self.model_name,
input_tokens=response.usage.prompt_tokens, # type: ignore
output_tokens=response.usage.completion_tokens, # type: ignore
)
return SchematicGenerationResult(
content=model_content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=response.usage.prompt_tokens, # type: ignore
output_tokens=response.usage.completion_tokens, # type: ignore
extra={},
),
),
)
except ValidationError:
self.logger.error(
f"JSON content returned by {self.model_name} does not match expected schema:\n{raw_content}"
)
raise
class FireworksLlama3_1_8B(FireworksSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="accounts/fireworks/models/llama-v3p1-8b-instruct",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
@property
@override
def tokenizer(self) -> FireworksEstimatingTokenizer:
return self._tokenizer
class FireworksLlama3_1_70B(FireworksSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="accounts/fireworks/models/llama-v3p1-70b-instruct",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
@property
@override
def tokenizer(self) -> FireworksEstimatingTokenizer:
return self._tokenizer
class FireworksLlama3_1_405B(FireworksSchematicGenerator[T]):
"""
@warn: This is an extremely large model (405B parameters).
Only suitable for high-performance workloads with significant budget considerations.
"""
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="accounts/fireworks/models/llama-v3p1-405b-instruct",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
@property
@override
def tokenizer(self) -> FireworksEstimatingTokenizer:
return self._tokenizer
class FireworksMythoMax(FireworksSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="accounts/fireworks/models/mythomax-l2-13b",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 4096
@property
@override
def tokenizer(self) -> FireworksEstimatingTokenizer:
return self._tokenizer
class FireworksGemma2_9B(FireworksSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="accounts/fireworks/models/gemma2-9b-it",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 8192
@property
@override
def tokenizer(self) -> FireworksEstimatingTokenizer:
return self._tokenizer
class CustomFireworksSchematicGenerator(FireworksSchematicGenerator[T]):
"""Generic Fireworks generator that accepts any model name."""
def __init__(self, model_name: str, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name=model_name,
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 8192 # Default conservative limit
@property
@override
def tokenizer(self) -> FireworksEstimatingTokenizer:
return self._tokenizer
# Using JinaAIEmbedder for embeddings since Fireworks focuses on inference
class FireworksService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
required_vars = {
"FIREWORKS_API_KEY": "<your_api_key_here>",
"FIREWORKS_MODEL": "accounts/fireworks/models/llama-v3p1-8b-instruct",
}
missing_vars = []
for var_name, default_value in required_vars.items():
if not os.environ.get(var_name):
if default_value:
missing_vars.append(f'export {var_name}="{default_value}"')
else:
missing_vars.append(f'export {var_name}="<your_{var_name.lower()}>"')
if missing_vars:
return f"""\
You're using the Fireworks NLP service, but the following environment variables are not set:
{chr(10).join(missing_vars)}
Please set these environment variables before running Parlant.
You can get your API key from: https://app.fireworks.ai/settings/users/api-keys
"""
return None
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
self._model_name = os.environ.get(
"FIREWORKS_MODEL", "accounts/fireworks/models/llama-v3p1-8b-instruct"
)
self._embedding_model = os.environ.get( # Need to be implemented
"FIREWORKS_EMBEDDING_MODEL", "accounts/fireworks/models/qwen3-embedding-8b"
)
self._logger = logger
self._tracer = tracer
self._meter = meter
self._health_reporter = health_reporter
self._logger.info(f"Initialized FireworksService with {self._model_name}")
@property
@override
def supports_streaming(self) -> bool:
return False
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
raise NotImplementedError("Streaming is not supported. Check supports_streaming first.")
def _get_specialized_generator_class(
self,
model_name: str,
schema_type: type[T],
) -> type[FireworksSchematicGenerator[T]] | None:
model_to_class: dict[str, type[FireworksSchematicGenerator[T]]] = {
"accounts/fireworks/models/llama-v3p1-8b-instruct": FireworksLlama3_1_8B[schema_type], # type: ignore
"accounts/fireworks/models/llama-v3p1-70b-instruct": FireworksLlama3_1_70B[schema_type], # type: ignore
"accounts/fireworks/models/llama-v3p1-405b-instruct": FireworksLlama3_1_405B[
schema_type # type: ignore
],
"accounts/fireworks/models/mythomax-l2-13b": FireworksMythoMax[schema_type], # type: ignore
"accounts/fireworks/models/gemma2-9b-it": FireworksGemma2_9B[schema_type], # type: ignore
}
return model_to_class.get(model_name)
def _log_model_warnings(self, model_name: str) -> None:
"""Log warnings for resource-intensive models."""
if "405b" in model_name.lower():
self._logger.warning(
f"Using {model_name} - This is an extremely large model with significant cost implications. "
"Consider using smaller models for development and testing."
)
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> FireworksSchematicGenerator[T]:
"""Get a schematic generator for the specified type."""
self._log_model_warnings(self._model_name)
specialized_class = self._get_specialized_generator_class(self._model_name, schema_type=t)
if specialized_class:
self._logger.debug(f"Using specialized generator for model: {self._model_name}")
return specialized_class(
model_name=self._model_name,
logger=self._logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
else:
self._logger.debug(f"Using custom generator for model: {self._model_name}")
return CustomFireworksSchematicGenerator[t]( # type: ignore
model_name=self._model_name,
logger=self._logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
return JinaAIEmbedder(self._logger, self._tracer, self._meter, self._health_reporter)
@override
async def get_moderation_service(self) -> ModerationService:
"""Fireworks doesn't provide moderation services, so we use no moderation."""
return NoModeration()
MODEL_RECOMMENDATIONS = {
"accounts/fireworks/models/llama-v3p1-8b-instruct": "Fast and cost-effective for most use cases",
"accounts/fireworks/models/llama-v3p1-70b-instruct": "High accuracy for complex reasoning tasks",
"accounts/fireworks/models/llama-v3p1-405b-instruct": "@warn: Extremely expensive, use only for critical workloads",
"accounts/fireworks/models/gemma2-9b-it": "Good balance of speed and accuracy",
"accounts/fireworks/models/mythomax-l2-13b": "Creative writing and roleplay scenarios",
}
+602
View File
@@ -0,0 +1,602 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import enum
import inspect
import os
import time
import types
from google.api_core.exceptions import NotFound, TooManyRequests, ResourceExhausted, ServerError
import google.genai # type: ignore
import google.genai.types # type: ignore
from collections.abc import Mapping as MappingABC, Sequence as SequenceABC
from typing import Any, Literal, Mapping, Sequence, Union, cast
from typing_extensions import get_args, get_origin, override
from pydantic import BaseModel, Field, ValidationError
from pydantic.fields import FieldInfo
from parlant.core.common import DefaultBaseModel
from parlant.adapters.nlp.common import record_llm_metrics
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.meter import Meter
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.nlp.moderation import ModerationService, NoModeration
from parlant.core.nlp.service import (
EmbedderHints,
ModelSize,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.embedding import BaseEmbedder, Embedder, EmbeddingResult
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
FallbackSchematicGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.loggers import Logger
from parlant.core.tracer import Tracer
from parlant.core.health import HealthReporter
RATE_LIMIT_ERROR_MESSAGE = (
"Google API rate limit exceeded.\n\n"
"Possible reasons:\n"
"1. Insufficient API credits in your account.\n"
"2. Using a free-tier account with limited request capacity.\n"
"3. Exceeded the requests-per-minute limit for your account.\n\n"
"Recommended actions:\n"
"- Check your Google API account balance and billing status.\n"
"- Review your API usage limits in the Google Cloud Console.\n"
"- Learn more about quotas and limits:\n"
" https://cloud.google.com/docs/quota-and-billing/quotas/quotas-overview"
)
class GoogleEstimatingTokenizer(EstimatingTokenizer):
def __init__(self, client: google.genai.Client, model_name: str) -> None:
self._client = client
self._model_name = model_name
@override
async def estimate_token_count(self, prompt: str) -> int:
model_approximation = {
"gemini-embedding-001": "gemini-2.5-flash",
}.get(self._model_name, self._model_name)
result = await self._client.aio.models.count_tokens(
model=model_approximation,
contents=prompt,
)
return int(result.total_tokens or 0)
class GeminiSchematicGenerator(BaseSchematicGenerator[T]):
supported_hints = ["temperature", "thinking_config"]
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._client = google.genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
self._tokenizer = GoogleEstimatingTokenizer(client=self._client, model_name=self.model_name)
@property
@override
def id(self) -> str:
return f"google/{self.model_name}"
@property
@override
def tokenizer(self) -> EstimatingTokenizer:
return self._tokenizer
@policy(
[
retry(
exceptions=(
NotFound,
TooManyRequests,
ResourceExhausted,
)
),
retry(ServerError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
with self.logger.scope(f"Gemini LLM Request ({self.schema.__name__})"):
return await self._do_generate(prompt, hints)
async def _do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
gemini_api_arguments = {k: v for k, v in hints.items() if k in self.supported_hints}
fd = self._get_schema_function_declaration()
config = google.genai.types.GenerateContentConfig(
tools=[google.genai.types.Tool(function_declarations=[fd])],
tool_config=google.genai.types.ToolConfig(
function_calling_config=google.genai.types.FunctionCallingConfig(
mode=google.genai.types.FunctionCallingConfigMode.ANY,
allowed_function_names=[fd.name],
)
),
**gemini_api_arguments, # type: ignore
)
t_start = time.time()
try:
response = await self._client.aio.models.generate_content(
model=self.model_name,
contents=prompt,
config=config,
)
except TooManyRequests:
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise
t_end = time.time()
assert response.candidates
assert response.candidates[0].content
assert response.candidates[0].content.parts
assert response.candidates[0].content.parts[0].function_call
assert response.candidates[0].content.parts[0].function_call.args
json_result = (
response.candidates[0].content.parts[0].function_call.args.get("log_data", {}) or {}
)
if response.usage_metadata:
self.logger.trace(response.usage_metadata.model_dump_json(indent=2))
try:
model_content = self.schema.model_validate(json_result)
await record_llm_metrics(
self.meter,
self.model_name,
schema_name=self.schema.__name__,
input_tokens=response.usage_metadata.prompt_token_count or 0
if response.usage_metadata
else 0,
output_tokens=response.usage_metadata.candidates_token_count or 0
if response.usage_metadata
else 0,
cached_input_tokens=response.usage_metadata.cached_content_token_count or 0
if response.usage_metadata
else 0,
)
return SchematicGenerationResult(
content=model_content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=response.usage_metadata.prompt_token_count or 0,
output_tokens=response.usage_metadata.candidates_token_count or 0,
extra={
"cached_input_tokens": (
response.usage_metadata.cached_content_token_count or 0
if response.usage_metadata
else 0
)
or 0
},
)
if response.usage_metadata
else UsageInfo(input_tokens=0, output_tokens=0, extra={}),
),
)
except ValidationError:
self.logger.error(
f"JSON content returned by {self.model_name} does not match expected schema:\n{json_result}"
)
raise
def _get_schema_function_declaration(self) -> google.genai.types.FunctionDeclaration:
# Create a signature from parameters
sig = inspect.Signature(
parameters=[
inspect.Parameter(
name="log_data",
kind=inspect.Parameter.POSITIONAL_OR_KEYWORD,
annotation=convert_model_to_gemini_compatible_schema(self.schema),
)
],
return_annotation=bool,
)
# Create a fake callable
def log_data() -> None:
pass
# Attach the signature
log_data.__signature__ = sig # type: ignore
fd = google.genai.types.FunctionDeclaration.from_callable(
callable=log_data,
client=self._client, # type: ignore
)
return fd
class Gemini_2_0_Flash(GeminiSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="gemini-2.0-flash",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 1024 * 1024
class Gemini_2_0_Flash_Lite(GeminiSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="gemini-2.0-flash-lite-preview-02-05",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 1024 * 1024
class Gemini_2_5_Flash(GeminiSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="gemini-2.5-flash",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@override
async def generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
return await super().generate(
prompt,
{"thinking_config": {"thinking_budget": 0}, **hints},
)
@property
@override
def max_tokens(self) -> int:
return 1024 * 1024
class Gemini_2_5_Flash_Lite(GeminiSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="gemini-2.5-flash-lite",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@override
async def generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
return await super().generate(
prompt,
{"thinking_config": {"thinking_budget": 0}, **hints},
)
@property
@override
def max_tokens(self) -> int:
return 1024 * 1024
class Gemini_2_5_Pro(GeminiSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="gemini-2.5-pro",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 1024 * 1024
class GoogleEmbedder(BaseEmbedder):
supported_hints = ["title", "task_type"]
def __init__(self, model_name: str, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(logger, tracer, meter, model_name, health_reporter)
self._client = google.genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
self._tokenizer = GoogleEstimatingTokenizer(client=self._client, model_name=self.model_name)
@property
@override
def id(self) -> str:
return f"google/{self.model_name}"
@property
@override
def tokenizer(self) -> GoogleEstimatingTokenizer:
return self._tokenizer
@policy(
[
retry(
exceptions=(
NotFound,
TooManyRequests,
ResourceExhausted,
)
),
retry(ServerError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_embed(
self,
texts: list[str],
hints: Mapping[str, Any] = {},
) -> EmbeddingResult:
gemini_api_arguments = {k: v for k, v in hints.items() if k in self.supported_hints}
try:
response = await self._client.aio.models.embed_content( # type: ignore
model=self.model_name,
contents=texts, # type: ignore
config=cast(google.genai.types.EmbedContentConfigDict, gemini_api_arguments),
)
except TooManyRequests:
self.logger.error(
(
"Google API rate limit exceeded. Possible reasons:\n"
"1. Your account may have insufficient API credits.\n"
"2. You may be using a free-tier account with limited request capacity.\n"
"3. You might have exceeded the requests-per-minute limit for your account.\n\n"
"Recommended actions:\n"
"- Check your Google API account balance and billing status.\n"
"- Review your API usage limits in Google's dashboard.\n"
"- For more details on rate limits and usage tiers, visit:\n"
" https://cloud.google.com/docs/quota-and-billing/quotas/quotas-overview"
),
)
raise
vectors = [
data_point.values for data_point in response.embeddings or [] if data_point.values
]
return EmbeddingResult(vectors=vectors)
class GeminiTextEmbedding_001(GoogleEmbedder):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="gemini-embedding-001",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 2048
@property
def dimensions(self) -> int:
return 3072
class GeminiService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
if not os.environ.get("GEMINI_API_KEY"):
return """\
You're using the GEMINI NLP service, but GEMINI_API_KEY is not set.
Please set GEMINI_API_KEY in your environment before running Parlant.
"""
return None
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
self.logger = logger
self._tracer = tracer
self._meter = meter
self._health_reporter = health_reporter
self.logger.info("Initialized GeminiService")
@property
@override
def supports_streaming(self) -> bool:
return False
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
raise NotImplementedError("Streaming is not supported. Check supports_streaming first.")
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> GeminiSchematicGenerator[T]:
match hints.get("model_size", ModelSize.AUTO):
case ModelSize.NANO:
return Gemini_2_5_Flash_Lite[t](self.logger, self._tracer, self._meter) # type: ignore
case ModelSize.MINI:
return Gemini_2_5_Flash[t](self.logger, self._tracer, self._meter) # type: ignore
case ModelSize.LARGE:
return Gemini_2_5_Pro[t](self.logger, self._tracer, self._meter) # type: ignore
case _:
return FallbackSchematicGenerator[t]( # type: ignore
Gemini_2_5_Flash[t](self.logger, self._tracer, self._meter), # type: ignore
Gemini_2_5_Pro[t](self.logger, self._tracer, self._meter), # type: ignore
logger=self.logger,
)
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
return GeminiTextEmbedding_001(self.logger, self._tracer, self._meter, self._health_reporter)
@override
async def get_moderation_service(self) -> ModerationService:
return NoModeration()
def convert_type_annotation_to_gemini_compatible_schema(annotation: Any) -> Any:
origin = get_origin(annotation)
# If not a generic type, check if it's a BaseModel or Enum
if origin is None:
# If it's an Enum class, convert to Literal of its values
if inspect.isclass(annotation) and issubclass(annotation, enum.Enum):
enum_values = tuple(member.value for member in annotation)
if len(enum_values) == 1:
return Literal[enum_values[0]]
return Literal.__getitem__(enum_values)
# If it's a BaseModel class, recursively convert it
if inspect.isclass(annotation) and issubclass(annotation, DefaultBaseModel):
return convert_model_to_gemini_compatible_schema(annotation)
return annotation
# Get the type arguments
args = get_args(annotation)
# Convert nested types recursively
converted_args = tuple(convert_type_annotation_to_gemini_compatible_schema(arg) for arg in args)
# Check if origin is Mapping or Sequence
if origin is Mapping or origin is MappingABC:
return dict[converted_args] if converted_args else dict # type: ignore
if origin is Sequence or origin is SequenceABC:
return list[converted_args] if converted_args else list # type: ignore
# Handle UnionType (X | Y syntax) - not subscriptable!
if origin is types.UnionType:
return Union[converted_args]
# For other generic types, preserve the origin with converted args
if converted_args:
return origin[converted_args]
return annotation
def convert_model_to_gemini_compatible_schema(model_cls: type[DefaultBaseModel]) -> type[BaseModel]:
"""
Create a new BaseModel class with converted annotations.
Returns a new class without modifying the original.
"""
# Avoid infinite recursion - check if already converted
if hasattr(model_cls, "_conversion_cache"):
return cast(type[BaseModel], model_cls._conversion_cache)
# Build new annotations
new_annotations = {}
new_fields = {}
for field_name, field_info in model_cls.model_fields.items():
# Convert the annotation
converted_annotation = convert_type_annotation_to_gemini_compatible_schema(
field_info.annotation
)
new_annotations[field_name] = converted_annotation
# Preserve field metadata (default, description, etc.)
# We need to recreate the field with the new annotation
field_kwargs = {}
if field_info.default is not None and field_info.default is not FieldInfo:
field_kwargs["default"] = field_info.default
elif field_info.default_factory is not None:
field_kwargs["default_factory"] = field_info.default_factory
if field_info.description is not None:
field_kwargs["description"] = field_info.description
if field_info.title is not None:
field_kwargs["title"] = field_info.title
if field_info.examples is not None:
field_kwargs["examples"] = field_info.examples
# Add other field properties as needed
if field_kwargs:
new_fields[field_name] = Field(**field_kwargs)
# Create new model class
new_model_attrs = {"__annotations__": new_annotations, **new_fields}
# Preserve model config if present
if hasattr(model_cls, "model_config"):
new_model_attrs["model_config"] = model_cls.model_config
# Create the new class
converted_model = type(f"{model_cls.__name__}Converted", (DefaultBaseModel,), new_model_attrs)
# Cache the conversion to avoid infinite recursion
setattr(model_cls, "_conversion_cache", converted_model)
return converted_model
+181
View File
@@ -0,0 +1,181 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from collections.abc import Mapping
import os
from pathlib import Path
from typing import Any
from typing_extensions import override
import torch # type: ignore
from typing import cast
from transformers import AutoModel, AutoTokenizer, PreTrainedTokenizerBase, PreTrainedModel # type: ignore
from huggingface_hub.errors import ( # type: ignore
InferenceTimeoutError,
InferenceEndpointError,
InferenceEndpointTimeoutError,
TextGenerationError,
)
from tempfile import gettempdir
from parlant.core.loggers import Logger
from parlant.core.tracer import Tracer
from parlant.core.meter import Meter
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.nlp.embedding import BaseEmbedder, EmbeddingResult
from parlant.core.health import HealthReporter
_TOKENIZER_MODELS: dict[str, PreTrainedTokenizerBase] = {}
_AUTO_MODELS: dict[str, PreTrainedModel] = {}
_DEVICE: torch.device | None = None
def _model_temp_dir() -> str:
return str(Path(gettempdir()) / "parlant_data" / "hf_models")
def _create_tokenizer(model_name: str) -> PreTrainedTokenizerBase:
if model_name in _TOKENIZER_MODELS:
return _TOKENIZER_MODELS[model_name]
save_dir = os.environ.get("PARLANT_HOME", _model_temp_dir())
os.makedirs(save_dir, exist_ok=True)
tokenizer: PreTrainedTokenizerBase = AutoTokenizer.from_pretrained(
model_name, trust_remote_code=True
) # type: ignore
tokenizer.save_pretrained(save_dir)
_TOKENIZER_MODELS[model_name] = tokenizer
return tokenizer
def _get_device() -> torch.device:
global _DEVICE
if _DEVICE:
return _DEVICE
if torch.backends.mps.is_available():
_DEVICE = torch.device("mps")
elif torch.cuda.is_available():
_DEVICE = torch.device("cuda")
else:
_DEVICE = torch.device("cpu")
return _DEVICE
def _create_auto_model(model_name: str) -> PreTrainedModel:
if model_name in _AUTO_MODELS:
return _AUTO_MODELS[model_name]
save_dir = os.environ.get("PARLANT_HOME", _model_temp_dir())
os.makedirs(save_dir, exist_ok=True)
model = AutoModel.from_pretrained(
pretrained_model_name_or_path=model_name,
attn_implementation="eager",
trust_remote_code=True,
).to(_get_device())
model = cast(PreTrainedModel, model)
model.save_pretrained(save_dir)
model.eval() # type: ignore
_AUTO_MODELS[model_name] = model
return model
class HuggingFaceEstimatingTokenizer(EstimatingTokenizer):
def __init__(self, model_name: str) -> None:
self.model_name = model_name
self._tokenizer = _create_tokenizer(model_name)
@override
async def estimate_token_count(self, prompt: str) -> int:
# Use encode to get token ids, which is always available
tokens = self._tokenizer.encode(prompt)
return len(tokens)
class HuggingFaceEmbedder(BaseEmbedder):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter, model_name: str) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._model = _create_auto_model(model_name)
self._tokenizer = HuggingFaceEstimatingTokenizer(model_name=model_name)
@property
@override
def id(self) -> str:
return f"hugging-face/{self.model_name}"
@property
@override
def max_tokens(self) -> int:
return 8192
@property
@override
def tokenizer(self) -> HuggingFaceEstimatingTokenizer:
return self._tokenizer
@policy(
[
retry(
exceptions=(
InferenceTimeoutError,
InferenceEndpointError,
InferenceEndpointTimeoutError,
),
max_exceptions=2,
),
retry(exceptions=(TextGenerationError), max_exceptions=3),
]
)
@override
async def do_embed(
self,
texts: list[str],
hints: Mapping[str, Any] = {},
) -> EmbeddingResult:
tokenized_texts = self._tokenizer._tokenizer.batch_encode_plus(
texts, padding=True, truncation=True, return_tensors="pt"
)
tokenized_texts = {key: value.to(_get_device()) for key, value in tokenized_texts.items()}
with torch.no_grad():
embeddings = self._model(**tokenized_texts).last_hidden_state[:, 0, :]
return EmbeddingResult(vectors=embeddings.tolist())
class JinaAIEmbedder(HuggingFaceEmbedder):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
logger=logger,
meter=meter, health_reporter=health_reporter,
tracer=tracer,
model_name="jinaai/jina-embeddings-v2-base-en",
)
@property
@override
def dimensions(self) -> int:
return 768
+93
View File
@@ -0,0 +1,93 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from itertools import chain
import os
from typing_extensions import override
import httpx
from parlant.core.health import HealthReporter
from parlant.core.loggers import Logger
from parlant.core.nlp.moderation import (
CustomerModerationContext,
ModerationCheck,
BaseModerationService,
ModerationTag,
)
from parlant.core.meter import Meter
class LakeraGuard(BaseModerationService):
def __init__(self, logger: Logger, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(logger, meter, health_reporter)
@override
async def do_moderate(self, context: CustomerModerationContext) -> ModerationCheck:
api_key: str | None = os.environ.get("LAKERA_API_KEY")
if not api_key:
self.logger.warning(
"LakeraGuard is enabled but LAKERA_API_KEY is missing. Skipping check..."
)
return ModerationCheck(flagged=False, tags=[])
def extract_tags(category: str) -> list[ModerationTag]:
mapping: dict[str, list[ModerationTag]] = {
"moderated_content_crime": ["illicit"],
"moderated_content_hate": ["hate"],
"moderated_content_profanity": ["harassment"],
"moderated_content_sexual": ["sexual"],
"moderated_content_violence": ["violence"],
"prompt_attack": ["jailbreak"],
}
return mapping.get(category.replace("/", "_").replace("-", "_"), [])
with self.logger.scope("Lakera Moderation Request"):
async with httpx.AsyncClient(follow_redirects=True, timeout=30) as client:
response = await client.post(
"https://api.lakera.ai/v2/guard/results",
json={"messages": [{"content": context.message, "role": "user"}]},
headers={"Authorization": f"Bearer {api_key}"},
)
if response.is_error:
raise Exception("Moderation service failure (Lakera Guard)")
data = response.json()
results = [
(
r["detector_type"],
{
"l1_confident": True,
"l2_very_likely": True,
"l3_likely": True,
"l4_less_likely": False,
"l5_unlikely": False,
}.get(r["result"], False),
)
for r in data["results"]
]
return ModerationCheck(
flagged=any(detected for _category, detected in results),
tags=list(
set(
chain.from_iterable(
extract_tags(category) for category, detected in results if detected
)
)
),
)
+348
View File
@@ -0,0 +1,348 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import time
from typing import Any, Mapping
from typing_extensions import override
import json
import jsonfinder # type: ignore
import os
from pydantic import ValidationError
import tiktoken
import litellm
from parlant.adapters.nlp.common import normalize_json_output, record_llm_metrics
from parlant.adapters.nlp.hugging_face import JinaAIEmbedder
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.loggers import Logger
from parlant.core.tracer import Tracer
from parlant.core.meter import Meter
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.nlp.service import (
EmbedderHints,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.embedding import BaseEmbedder, Embedder, EmbeddingResult
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.nlp.moderation import (
ModerationService,
NoModeration,
)
from parlant.core.health import HealthReporter
RATE_LIMIT_ERROR_MESSAGE = (
"LiteLLM to provider API rate limit exceeded. Possible reasons:\n"
"1. Your account may have insufficient API credits.\n"
"2. You may be using a free-tier account with limited request capacity.\n"
"3. You might have exceeded the requests-per-minute limit for your account.\n\n"
"Recommended actions:\n"
"- Check your LLM Provider account balance and billing status.\n"
"- Review your API usage limits in Provider's dashboard.\n"
"- For more details on rate limits and usage tiers, visit:\n"
" Your Provider's API documentation."
)
class LiteLLMEstimatingTokenizer(EstimatingTokenizer):
def __init__(self, model_name: str) -> None:
self.model_name = model_name
self.encoding = tiktoken.encoding_for_model("gpt-4o-2024-08-06")
@override
async def estimate_token_count(self, prompt: str) -> int:
tokens = self.encoding.encode(prompt)
return len(tokens)
class LiteLLMSchematicGenerator(BaseSchematicGenerator[T]):
supported_litellm_params = [
"temperature",
"max_tokens",
"logit_bias",
"adapter_id",
"adapter_source",
]
supported_hints = supported_litellm_params + ["strict"]
def __init__(self,
base_url: str | None,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self.base_url = base_url
self._client = litellm
self._tokenizer = LiteLLMEstimatingTokenizer(model_name=self.model_name)
@property
@override
def id(self) -> str:
return f"litellm/{self.model_name}"
@property
@override
def tokenizer(self) -> LiteLLMEstimatingTokenizer:
return self._tokenizer
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
litellm_api_arguments = {
k: v for k, v in hints.items() if k in self.supported_litellm_params
}
# Only pass api_key if explicitly set; otherwise let LiteLLM auto-detect
# provider-specific keys (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.)
api_key = os.environ.get("LITELLM_PROVIDER_API_KEY")
t_start = time.time()
response = await self._client.acompletion(
base_url=self.base_url,
api_key=api_key,
messages=[{"role": "user", "content": prompt}],
model=self.model_name,
max_tokens=5000,
response_format={"type": "json_object"},
**litellm_api_arguments,
)
t_end = time.time()
if response.usage:
self.logger.trace(response.usage.model_dump_json(indent=2))
raw_content = response.choices[0].message.content or "{}"
try:
json_content = json.loads(normalize_json_output(raw_content))
except json.JSONDecodeError:
self.logger.warning(
f"Invalid JSON returned by litellm/{self.model_name}:\n{raw_content})"
)
json_content = jsonfinder.only_json(raw_content)[2]
self.logger.warning("Found JSON content within model response; continuing...")
try:
content = self.schema.model_validate(json_content)
assert response.usage
await record_llm_metrics(
self.meter,
self.model_name,
schema_name=self.schema.__name__,
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
cached_input_tokens=getattr(
response,
"usage.prompt_cache_hit_tokens",
0,
),
)
return SchematicGenerationResult(
content=content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
extra={
"cached_input_tokens": getattr(
response,
"usage.prompt_cache_hit_tokens",
0,
)
},
),
),
)
except ValidationError:
self.logger.error(
f"JSON content returned by litellm/{self.model_name} does not match expected schema:\n{raw_content}"
)
raise
class LiteLLM_Default(LiteLLMSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter, base_url: str | None, model_name: str
) -> None:
super().__init__(
base_url=base_url,
model_name=model_name,
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 5000
# 8192 16381
class LiteLLMEmbedder(BaseEmbedder):
"""Embedder that uses LiteLLM to access various embedding providers."""
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
base_url: str | None = None,
) -> None:
super().__init__(logger, tracer, meter, model_name, health_reporter)
self._base_url = base_url
self._client = litellm
self._tokenizer = LiteLLMEstimatingTokenizer(model_name=model_name)
@property
@override
def id(self) -> str:
return f"litellm/{self.model_name}"
@property
@override
def tokenizer(self) -> LiteLLMEstimatingTokenizer:
return self._tokenizer
@property
@override
def max_tokens(self) -> int:
return int(os.environ.get("LITELLM_EMBEDDING_MAX_TOKENS", 8192))
@property
@override
def dimensions(self) -> int:
return int(os.environ.get("LITELLM_EMBEDDING_DIMENSIONS", 1536))
@override
async def do_embed(
self,
texts: list[str],
hints: Mapping[str, Any] = {},
) -> EmbeddingResult:
api_key = os.environ.get("LITELLM_PROVIDER_API_KEY")
response = await self._client.aembedding(
model=self.model_name,
input=texts,
api_key=api_key,
api_base=self._base_url,
)
vectors = [data["embedding"] for data in response.data]
return EmbeddingResult(vectors=vectors)
class LiteLLMService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
if not os.environ.get("LITELLM_PROVIDER_MODEL_NAME"):
return """\
You're using the LITELLM NLP service, but LITELLM_PROVIDER_MODEL_NAME is not set.
Please set LITELLM_PROVIDER_MODEL_NAME in your environment before running Parlant.
"""
# Note: LITELLM_PROVIDER_API_KEY is optional. If not set, LiteLLM will
# auto-detect provider-specific keys (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.)
return None
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
self._base_url = os.environ.get("LITELLM_PROVIDER_BASE_URL")
self._model_name = os.environ["LITELLM_PROVIDER_MODEL_NAME"]
self._embedding_model_name = os.environ.get("LITELLM_EMBEDDING_MODEL_NAME")
self.logger = logger
self._tracer = tracer
self._meter = meter
self._health_reporter = health_reporter
log_msg = f"Initialized LiteLLMService with {self._model_name}"
if self._embedding_model_name:
log_msg += f" (embeddings: {self._embedding_model_name})"
if self._base_url:
log_msg += f" at {self._base_url}"
self.logger.info(log_msg)
@property
@override
def supports_streaming(self) -> bool:
return False
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
raise NotImplementedError("Streaming is not supported. Check supports_streaming first.")
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> LiteLLMSchematicGenerator[T]:
return LiteLLM_Default[t]( # type: ignore
self.logger,
self._tracer,
self._meter,
self._health_reporter,
self._base_url,
self._model_name,
)
def create_embedder(self) -> Embedder:
if self._embedding_model_name:
return LiteLLMEmbedder(
model_name=self._embedding_model_name,
logger=self.logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
base_url=self._base_url,
)
return JinaAIEmbedder(self.logger, self._tracer, self._meter, self._health_reporter)
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
return self.create_embedder()
@override
async def get_moderation_service(self) -> ModerationService:
return NoModeration()
+422
View File
@@ -0,0 +1,422 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import time
from typing import Any, Mapping
from typing_extensions import override
import json
import jsonfinder # type: ignore
import os
from pydantic import ValidationError
import tiktoken
from parlant.adapters.nlp.common import normalize_json_output, record_llm_metrics
from parlant.core.engines.alpha.canned_response_generator import CannedResponseSelectionSchema
from parlant.core.engines.alpha.guideline_matching.generic.disambiguation_batch import (
DisambiguationGuidelineMatchesSchema,
)
from parlant.core.engines.alpha.guideline_matching.generic.journey.journey_backtrack_node_selection import (
JourneyBacktrackNodeSelectionSchema,
)
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.loggers import Logger
from parlant.core.tracer import Tracer
from parlant.core.meter import Meter
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.nlp.service import (
EmbedderHints,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.embedding import BaseEmbedder, Embedder, EmbeddingResult
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.nlp.moderation import (
BaseModerationService,
CustomerModerationContext,
ModerationCheck,
ModerationService,
ModerationTag,
)
from parlant.core.health import HealthReporter
try:
from mistralai import Mistral
from mistralai.models import SDKError, HTTPValidationError
except ImportError:
Mistral = None # type: ignore
SDKError = Exception # type: ignore
HTTPValidationError = Exception # type: ignore
RATE_LIMIT_ERROR_MESSAGE = (
"Mistral AI API rate limit exceeded. Possible reasons:\n"
"1. Your account may have insufficient API credits.\n"
"2. You may be using a free-tier account with limited request capacity.\n"
"3. You might have exceeded the requests-per-minute limit for your account.\n\n"
"Recommended actions:\n"
"- Check your Mistral AI account balance and billing status.\n"
"- Review your API usage limits in Mistral AI's dashboard.\n"
"- For more details on rate limits and usage tiers, visit:\n"
" https://docs.mistral.ai/api/\n"
)
class MistralEstimatingTokenizer(EstimatingTokenizer):
def __init__(self, model_name: str) -> None:
self.model_name = model_name
# Use GPT-4o encoding as approximation for Mistral models
self.encoding = tiktoken.encoding_for_model("gpt-4o-2024-08-06")
@override
async def estimate_token_count(self, prompt: str) -> int:
tokens = self.encoding.encode(prompt)
return len(tokens)
class MistralSchematicGenerator(BaseSchematicGenerator[T]):
supported_mistral_params = ["temperature", "max_tokens"]
supported_hints = supported_mistral_params
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
self._tokenizer = MistralEstimatingTokenizer(model_name=self.model_name)
@property
@override
def id(self) -> str:
return f"mistral/{self.model_name}"
@property
@override
def tokenizer(self) -> MistralEstimatingTokenizer:
return self._tokenizer
@policy(
[
retry(
exceptions=(
ConnectionError,
TimeoutError,
SDKError,
HTTPValidationError,
),
),
]
)
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
with self.logger.scope(f"Mistral LLM Request ({self.schema.__name__})"):
return await self._do_generate(prompt, hints)
async def _do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
mistral_api_arguments = {
k: v for k, v in hints.items() if k in self.supported_mistral_params
}
t_start = time.time()
try:
response = await self._client.chat.complete_async(
messages=[{"role": "user", "content": prompt}], # type: ignore[arg-type]
model=self.model_name,
response_format={"type": "json_object"}, # type: ignore[arg-type]
**mistral_api_arguments,
)
except SDKError as e:
if "rate" in str(e).lower() or "429" in str(e):
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise
t_end = time.time()
if response.usage:
self.logger.trace(
f"Usage: input_tokens={response.usage.prompt_tokens}, "
f"output_tokens={response.usage.completion_tokens}"
)
raw_content = response.choices[0].message.content or "{}"
try:
# Convert content to string if needed
content_str = raw_content if isinstance(raw_content, str) else str(raw_content)
json_content = json.loads(normalize_json_output(content_str))
except json.JSONDecodeError:
self.logger.warning(f"Invalid JSON returned by {self.model_name}:\n{raw_content})")
json_content = jsonfinder.only_json(raw_content)[2]
self.logger.warning("Found JSON content within model response; continuing...")
try:
content = self.schema.model_validate(json_content)
assert response.usage
await record_llm_metrics(
self.meter,
self.model_name,
schema_name=self.schema.__name__,
input_tokens=response.usage.prompt_tokens or 0,
output_tokens=response.usage.completion_tokens or 0,
cached_input_tokens=0,
)
return SchematicGenerationResult(
content=content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=response.usage.prompt_tokens or 0,
output_tokens=response.usage.completion_tokens or 0,
),
),
)
except ValidationError as e:
self.logger.error(
f"Error: {e.json(indent=2)}\nJSON content returned by {self.model_name} does not match expected schema:\n{raw_content}"
)
raise
class Mistral_Large_2411(MistralSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="mistral-large-2411", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class Mistral_Medium_2508(MistralSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="mistral-medium-2508", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter
)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class Mistral_Small_2506(MistralSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="mistral-small-2506", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class MistralEmbedder(BaseEmbedder):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name="mistral-embed")
self._client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
self._tokenizer = MistralEstimatingTokenizer(model_name=self.model_name)
@property
@override
def id(self) -> str:
return f"mistral/{self.model_name}"
@property
@override
def tokenizer(self) -> MistralEstimatingTokenizer:
return self._tokenizer
@property
@override
def max_tokens(self) -> int:
return 8192
@property
def dimensions(self) -> int:
return 1024
@policy(
[
retry(
exceptions=(
ConnectionError,
TimeoutError,
SDKError,
HTTPValidationError,
),
),
]
)
@override
async def do_embed(
self,
texts: list[str],
hints: Mapping[str, Any] = {},
) -> EmbeddingResult:
try:
response = await self._client.embeddings.create_async(
model=self.model_name,
inputs=texts,
)
except SDKError as e:
if "rate" in str(e).lower() or "429" in str(e):
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise
vectors = [
data_point.embedding if data_point.embedding else [] for data_point in response.data
]
return EmbeddingResult(vectors=vectors)
class MistralModerationService(BaseModerationService):
def __init__(self, logger: Logger, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(logger=logger, meter=meter, health_reporter=health_reporter)
self.model_name = "mistral-moderation-2411"
self._client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
@override
async def do_moderate(self, context: CustomerModerationContext) -> ModerationCheck:
def extract_tags(category: str) -> list[ModerationTag]:
mapping: dict[str, list[ModerationTag]] = {
"sexual": ["sexual"],
"hate_and_discrimination": ["hate"],
"violence_and_threats": ["violence"],
"dangerous_and_criminal_content": ["illicit"],
"selfharm": ["self-harm"],
"health": ["illicit"],
"financial": ["illicit"],
"law": ["illicit"],
"pii": ["illicit"],
}
return mapping.get(category.replace("-", "_").replace(" ", "_").lower(), [])
response = await self._client.classifiers.moderate_chat_async(
model=self.model_name,
inputs=[{"role": "user", "content": context.message}], # type: ignore[arg-type]
)
result = response.results[0]
flagged = False
all_tags: list[ModerationTag] = []
if result.categories:
for category_result in result.categories:
# Type check since the API may return different formats
if hasattr(category_result, "category_scores") and category_result.category_scores:
# Check if any score indicates flagged content (threshold can be adjusted)
for score_item in category_result.category_scores:
if (
hasattr(score_item, "score")
and score_item.score
and score_item.score > 0.5
):
flagged = True
if hasattr(category_result, "category"):
all_tags.extend(extract_tags(str(category_result.category)))
break
return ModerationCheck(
flagged=flagged,
tags=list(set(all_tags)),
)
class MistralService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
if not os.environ.get("MISTRAL_API_KEY"):
return """\
You're using the Mistral NLP service, but MISTRAL_API_KEY is not set.
Please set MISTRAL_API_KEY in your environment before running Parlant.
"""
return None
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
self._logger = logger
self._tracer = tracer
self._meter = meter
self._health_reporter = health_reporter
self._logger.info("Initialized MistralService")
@property
@override
def supports_streaming(self) -> bool:
return False
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
raise NotImplementedError("Streaming is not supported. Check supports_streaming first.")
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> MistralSchematicGenerator[T]:
if (
t == JourneyBacktrackNodeSelectionSchema
or t == DisambiguationGuidelineMatchesSchema
or t == CannedResponseSelectionSchema
):
return Mistral_Large_2411[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
return Mistral_Medium_2508[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
return MistralEmbedder(self._logger, self._tracer, self._meter, self._health_reporter)
@override
async def get_moderation_service(self) -> ModerationService:
return MistralModerationService(self._logger, self._meter, self._health_reporter)
@@ -0,0 +1,263 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Maintainer: Rongkun Yan <2493404415@qq.com>
from __future__ import annotations
import time
from openai import (
APIConnectionError,
APIResponseValidationError,
APITimeoutError,
AsyncClient,
ConflictError,
InternalServerError,
RateLimitError,
)
from typing import Any, Mapping
from typing_extensions import override
import json
import jsonfinder # type: ignore
import os
from pydantic import ValidationError
import tiktoken
from parlant.adapters.nlp.common import normalize_json_output, record_llm_metrics
from parlant.adapters.nlp.hugging_face import JinaAIEmbedder
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.loggers import Logger
from parlant.core.tracer import Tracer
from parlant.core.meter import Meter
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.nlp.service import (
EmbedderHints,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.embedding import Embedder
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.nlp.moderation import (
ModerationService,
NoModeration,
)
from parlant.core.health import HealthReporter
class ModelScopeEstimatingTokenizer(EstimatingTokenizer):
def __init__(self, model_name: str) -> None:
self.model_name = model_name
self.encoding = tiktoken.encoding_for_model("gpt-4o-2024-08-06")
@override
async def estimate_token_count(self, prompt: str) -> int:
tokens = self.encoding.encode(prompt)
return len(tokens)
class ModelScopeSchematicGenerator(BaseSchematicGenerator[T]):
supported_modelscope_params = ["temperature", "logit_bias", "max_tokens"]
supported_hints = supported_modelscope_params + ["strict"]
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._client = AsyncClient(
base_url="https://api-inference.modelscope.cn/v1",
api_key=os.environ["MODELSCOPE_API_KEY"],
)
self._tokenizer = ModelScopeEstimatingTokenizer(model_name=self.model_name)
@property
@override
def id(self) -> str:
return f"modelscope/{self.model_name}"
@property
@override
def tokenizer(self) -> ModelScopeEstimatingTokenizer:
return self._tokenizer
@policy(
[
retry(
exceptions=(
APIConnectionError,
APITimeoutError,
ConflictError,
RateLimitError,
APIResponseValidationError,
),
),
retry(InternalServerError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
with self.logger.scope(f"ModelScope LLM Request ({self.schema.__name__})"):
return await self._do_generate(prompt, hints)
async def _do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
modelscope_api_arguments = {
k: v for k, v in hints.items() if k in self.supported_modelscope_params
}
t_start = time.time()
response = await self._client.chat.completions.create(
messages=[{"role": "user", "content": prompt}],
model=self.model_name,
stream=True,
extra_body={"enable_thinking": False},
max_tokens=8192,
response_format={"type": "json_object"},
**modelscope_api_arguments,
)
t_end = time.time()
raw_content = ""
async for chunk in response:
if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.content:
raw_content += chunk.choices[0].delta.content
try:
json_content = json.loads(normalize_json_output(raw_content))
except json.JSONDecodeError:
self.logger.warning(f"Invalid JSON returned by {self.model_name}:\n{raw_content})")
json_content = jsonfinder.only_json(raw_content)[2]
self.logger.warning("Found JSON content within model response; continuing...")
try:
content = self.schema.model_validate(json_content)
input_tokens = await self.tokenizer.estimate_token_count(prompt)
output_tokens = await self.tokenizer.estimate_token_count(raw_content)
await record_llm_metrics(
self.meter,
self.model_name,
schema_name=self.schema.__name__,
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_input_tokens=0,
)
return SchematicGenerationResult(
content=content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=input_tokens,
output_tokens=output_tokens,
extra={},
),
),
)
except ValidationError as ve:
self.logger.error(
f"JSON content returned by {self.model_name} does not match expected schema:\n{raw_content}"
)
self.logger.error(f"Validation error details: {str(ve)}")
raise
class ModelScopeChat(ModelScopeSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
model_name = os.environ["MODELSCOPE_MODEL_NAME"]
super().__init__(model_name=model_name, logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class ModelScopeService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
if not os.environ.get("MODELSCOPE_MODEL_NAME"):
return """\
You're using the ModelScope NLP service, but MODELSCOPE_MODEL_NAME is not set.
Please set MODELSCOPE_MODEL_NAME in your environment before running Parlant.
"""
if not os.environ.get("MODELSCOPE_API_KEY"):
return """\
You're using the ModelScope NLP service, but MODELSCOPE_API_KEY is not set.
Please set MODELSCOPE_API_KEY in your environment before running Parlant.
"""
return None
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
self._logger = logger
self._tracer = tracer
self._meter = meter
self._health_reporter = health_reporter
self._logger.info("Initialized ModelScopeService")
@property
@override
def supports_streaming(self) -> bool:
return False
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
raise NotImplementedError("Streaming is not supported. Check supports_streaming first.")
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> ModelScopeSchematicGenerator[T]:
return ModelScopeChat[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
return JinaAIEmbedder(self._logger, self._tracer, self._meter, self._health_reporter)
@override
async def get_moderation_service(self) -> ModerationService:
return NoModeration()
+494
View File
@@ -0,0 +1,494 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import time
from openai import (
APIConnectionError,
APIResponseValidationError,
APITimeoutError,
AsyncClient,
ConflictError,
InternalServerError,
RateLimitError,
)
import re
from typing import Any, AsyncIterator, Callable, Mapping
from typing_extensions import override
import json
import jsonfinder # type: ignore
import os
from pydantic import ValidationError
import tiktoken
from parlant.adapters.nlp.common import normalize_json_output, record_llm_metrics
from parlant.adapters.nlp.hugging_face import JinaAIEmbedder
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.loggers import Logger
from parlant.core.tracer import Tracer
from parlant.core.meter import Meter
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.nlp.service import (
EmbedderHints,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.embedding import Embedder
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
BaseStreamingTextGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.nlp.moderation import (
ModerationService,
NoModeration,
)
from parlant.core.health import HealthReporter
NOVITA_BASE_URL = "https://api.novita.ai/openai"
NOVITA_DEFAULT_MODEL = "moonshotai/kimi-k2.5"
class NovitaEstimatingTokenizer(EstimatingTokenizer):
def __init__(self, model_name: str) -> None:
self.model_name = model_name
self.encoding = tiktoken.encoding_for_model("gpt-4o-2024-08-06")
@override
async def estimate_token_count(self, prompt: str) -> int:
tokens = self.encoding.encode(prompt)
return len(tokens)
class NovitaSchematicGenerator(BaseSchematicGenerator[T]):
supported_novita_params = ["temperature", "logit_bias", "max_tokens"]
supported_hints = supported_novita_params + ["strict"]
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._client = AsyncClient(
base_url=NOVITA_BASE_URL,
api_key=os.environ["NOVITA_API_KEY"],
)
self._tokenizer = NovitaEstimatingTokenizer(model_name=self.model_name)
@property
@override
def id(self) -> str:
return f"novita/{self.model_name}"
@property
@override
def tokenizer(self) -> NovitaEstimatingTokenizer:
return self._tokenizer
@policy(
[
retry(
exceptions=(
APIConnectionError,
APITimeoutError,
ConflictError,
RateLimitError,
APIResponseValidationError,
),
),
retry(InternalServerError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
with self.logger.scope(f"Novita LLM Request ({self.schema.__name__})"):
return await self._do_generate(prompt, hints)
async def _do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
novita_api_arguments = {k: v for k, v in hints.items() if k in self.supported_novita_params}
t_start = time.time()
response = await self._client.chat.completions.create(
messages=[{"role": "user", "content": prompt}],
model=self.model_name,
max_tokens=8192,
response_format={"type": "json_object"},
**novita_api_arguments,
)
t_end = time.time()
if response.usage:
self.logger.trace(response.usage.model_dump_json(indent=2))
raw_content = response.choices[0].message.content or "{}"
try:
json_content = json.loads(normalize_json_output(raw_content))
except json.JSONDecodeError:
self.logger.warning(f"Invalid JSON returned by {self.model_name}:\n{raw_content})")
json_content = jsonfinder.only_json(raw_content)[2]
self.logger.warning("Found JSON content within model response; continuing...")
try:
content = self.schema.model_validate(json_content)
assert response.usage
await record_llm_metrics(
self.meter,
self.model_name,
schema_name=self.schema.__name__,
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
cached_input_tokens=getattr(
response.usage,
"prompt_cache_hit_tokens",
0,
),
)
return SchematicGenerationResult(
content=content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
extra={
"cached_input_tokens": getattr(
response.usage,
"prompt_cache_hit_tokens",
0,
)
},
),
),
)
except ValidationError:
self.logger.error(
f"JSON content returned by {self.model_name} does not match expected schema:\n{raw_content}"
)
raise
class Novita_KimiK2(NovitaSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="moonshotai/kimi-k2.5", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter
)
@property
@override
def max_tokens(self) -> int:
return 262_144
class Novita_DeepSeekV3(NovitaSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="deepseek/deepseek-v3.2", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter
)
@property
@override
def max_tokens(self) -> int:
return 163_840
class Novita_GLM5(NovitaSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="zai-org/glm-5.1", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
@property
@override
def max_tokens(self) -> int:
return 204_800
class Novita_MinimaxM2(NovitaSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="minimax/minimax-m2.7", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter
)
@property
@override
def max_tokens(self) -> int:
return 204_800
class CustomNovitaSchematicGenerator(NovitaSchematicGenerator[T]):
"""Generic Novita AI generator that accepts any model name."""
def __init__(self, model_name: str, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name=model_name,
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
# ============================================================================
# Streaming Text Generators
# ============================================================================
# Pattern to detect word boundaries for chunking
# Matches after any whitespace character
_WORD_BOUNDARY_PATTERN = re.compile(r"(?<=\s)")
# Number of words to buffer before yielding a chunk
_WORDS_PER_CHUNK = 3
class NovitaStreamingTextGenerator(BaseStreamingTextGenerator):
"""Streaming text generator using Novita AI's OpenAI-compatible streaming API.
Buffers tokens into word-sized chunks for smoother frontend rendering.
"""
supported_novita_params = ["temperature", "max_tokens"]
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._client = AsyncClient(
base_url=NOVITA_BASE_URL,
api_key=os.environ["NOVITA_API_KEY"],
)
self._tokenizer = NovitaEstimatingTokenizer(model_name=self.model_name)
@property
@override
def id(self) -> str:
return f"novita-streaming/{self.model_name}"
@property
@override
def tokenizer(self) -> NovitaEstimatingTokenizer:
return self._tokenizer
def _list_arguments(self, hints: Mapping[str, Any]) -> Mapping[str, Any]:
return {k: v for k, v in hints.items() if k in self.supported_novita_params}
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> tuple[AsyncIterator[str | None], Callable[[], UsageInfo]]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
novita_api_arguments = self._list_arguments(hints)
stream = await self._client.chat.completions.create(
messages=[{"role": "user", "content": prompt}],
model=self.model_name,
stream=True,
stream_options={"include_usage": True},
**novita_api_arguments,
)
# Track usage from final chunk
usage_info: UsageInfo | None = None
async def chunk_generator() -> AsyncIterator[str | None]:
nonlocal usage_info
# Buffer for accumulating tokens into word-sized chunks
buffer = ""
async for chunk in stream:
# Check for usage in final chunk (when stream_options include_usage is set)
if chunk.usage is not None:
self.logger.trace(chunk.usage.model_dump_json(indent=2))
cached_tokens = (
getattr(
chunk.usage,
"prompt_cache_hit_tokens",
0,
)
or 0
)
usage_info = UsageInfo(
input_tokens=chunk.usage.prompt_tokens,
output_tokens=chunk.usage.completion_tokens,
extra={"cached_input_tokens": cached_tokens},
)
if chunk.choices and chunk.choices[0].delta.content:
token = chunk.choices[0].delta.content
buffer += token
# Count word boundaries in buffer
boundaries = list(_WORD_BOUNDARY_PATTERN.finditer(buffer))
if len(boundaries) >= _WORDS_PER_CHUNK:
# Yield up to the last complete word boundary
last_boundary = boundaries[_WORDS_PER_CHUNK - 1]
chunk_text = buffer[: last_boundary.end()]
buffer = buffer[last_boundary.end() :]
yield chunk_text
# Yield any remaining content in the buffer
if buffer:
yield buffer
# Record metrics if we have usage info
if usage_info is not None:
await record_llm_metrics(
self.meter,
self.model_name,
schema_name="streaming",
input_tokens=usage_info.input_tokens,
output_tokens=usage_info.output_tokens,
cached_input_tokens=usage_info.extra.get("cached_input_tokens", 0)
if usage_info.extra
else 0,
)
# Signal completion
yield None
def get_usage() -> UsageInfo:
if usage_info is None:
# Fallback if usage wasn't available
return UsageInfo(input_tokens=0, output_tokens=0)
return usage_info
return chunk_generator(), get_usage
class NovitaService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
if not os.environ.get("NOVITA_API_KEY"):
return """\
You're using the Novita AI NLP service, but NOVITA_API_KEY is not set.
Please set NOVITA_API_KEY in your environment before running Parlant.
"""
return None
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
self.model_name = os.environ.get("NOVITA_MODEL", NOVITA_DEFAULT_MODEL)
self._logger = logger
self._tracer = tracer
self._meter = meter
self._health_reporter = health_reporter
self._logger.info(f"Initialized NovitaService with model: {self.model_name}")
@property
@override
def supports_streaming(self) -> bool:
return True
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
return NovitaStreamingTextGenerator(
model_name=self.model_name,
logger=self._logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
def _get_specialized_generator_class(
self,
model_name: str,
schema_type: type[T],
) -> Callable[[Logger, Tracer, Meter, HealthReporter], NovitaSchematicGenerator[T]] | None:
"""Returns the specialized generator class for known models, or None for custom models."""
model_to_class: dict[
str, Callable[[Logger, Tracer, Meter, HealthReporter], NovitaSchematicGenerator[T]]
] = {
"moonshotai/kimi-k2.5": Novita_KimiK2[schema_type], # type: ignore
"deepseek/deepseek-v3.2": Novita_DeepSeekV3[schema_type], # type: ignore
"zai-org/glm-5.1": Novita_GLM5[schema_type], # type: ignore
"minimax/minimax-m2.7": Novita_MinimaxM2[schema_type], # type: ignore
}
return model_to_class.get(model_name)
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> NovitaSchematicGenerator[T]:
specialized_class = self._get_specialized_generator_class(self.model_name, schema_type=t)
if specialized_class:
self._logger.debug(f"Using specialized generator for model: {self.model_name}")
return specialized_class(self._logger, self._tracer, self._meter, self._health_reporter)
else:
self._logger.debug(f"Using custom generator for model: {self.model_name}")
return CustomNovitaSchematicGenerator[t]( # type: ignore
model_name=self.model_name,
logger=self._logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
return JinaAIEmbedder(self._logger, self._tracer, self._meter, self._health_reporter)
@override
async def get_moderation_service(self) -> ModerationService:
return NoModeration()
+771
View File
@@ -0,0 +1,771 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Maintainer: Agam Dubey <hello.world.agam@gmail.com>
import os
import time
from typing import Any, Callable, Mapping
from typing_extensions import override
import asyncio
import tiktoken
import ollama
import jsonfinder # type: ignore
from pydantic import ValidationError
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.adapters.nlp.common import normalize_json_output, record_llm_metrics
from parlant.core.meter import Meter
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.nlp.moderation import ModerationService, NoModeration
from parlant.core.nlp.service import (
EmbedderHints,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.embedding import BaseEmbedder, Embedder, EmbeddingResult
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
SchematicGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.loggers import Logger
from parlant.core.tracer import Tracer
from parlant.core.health import HealthReporter
class OllamaError(Exception):
"""Base exception for Ollama-related errors."""
pass
class OllamaConnectionError(OllamaError):
"""Raised when unable to connect to Ollama server."""
pass
class OllamaModelError(OllamaError):
"""Raised when there are issues with the Ollama model."""
pass
class OllamaTimeoutError(OllamaError):
"""Raised when Ollama request times out."""
pass
class OllamaModelVerifier:
"""Utility class for verifying Ollama model availability."""
@staticmethod
def verify_models(base_url: str, generation_model: str, embedding_model: str) -> str | None:
"""
Returns an error string if required Ollama models are missing,
or None if all are available.
"""
client = ollama.Client(host=base_url.rstrip("/"))
try:
models = client.list()
model_names = []
for model in models.get("models", []):
if hasattr(model, "model"):
model_names.append(model.model)
elif isinstance(model, dict) and "model" in model:
model_names.append(model["model"])
elif isinstance(model, dict) and "name" in model:
model_names.append(model["name"])
missing_models = []
gen_model_found = any(generation_model in model for model in model_names)
if not gen_model_found and generation_model not in model_names:
missing_models.append(f" ollama pull {generation_model}")
embed_model_found = any(embedding_model in model for model in model_names)
if not embed_model_found and embedding_model not in model_names:
missing_models.append(f" ollama pull {embedding_model}")
if missing_models:
return f"""\
The following required models are not available in Ollama:
{chr(10).join(missing_models)}
Please pull the missing models using the commands above.
Available models: {", ".join(model_names) if model_names else "None"}
"""
return None
except ollama.ResponseError as e:
if e.status_code in [502, 503, 504]:
return f"""\
Cannot connect to Ollama server at {base_url}.
Please ensure Ollama is running:
ollama serve
Or check if the OLLAMA_BASE_URL is correct: {base_url}
"""
else:
return f"Error checking Ollama models: {e.error}"
except Exception as e:
return f"Error connecting to Ollama: {str(e)}"
class OllamaEstimatingTokenizer(EstimatingTokenizer):
"""Simple tokenizer that estimates token count for Ollama models."""
def __init__(self, model_name: str):
self.model_name = model_name
self.encoding = tiktoken.encoding_for_model("gpt-4o-2024-08-06")
@override
async def estimate_token_count(self, prompt: str) -> int:
"""Estimate token count using tiktoken"""
tokens = self.encoding.encode(prompt)
return int(len(tokens) * 1.15)
class OllamaSchematicGenerator(BaseSchematicGenerator[T]):
"""Schematic generator that uses Ollama models."""
supported_hints = ["temperature", "max_tokens", "top_p", "top_k", "repeat_penalty", "timeout"]
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
base_url: str = "http://localhost:11434",
default_timeout: int | str = 300,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self.base_url = base_url.rstrip("/")
self._tokenizer = OllamaEstimatingTokenizer(model_name)
self._default_timeout = default_timeout
self._client = ollama.AsyncClient(host=base_url)
@property
@override
def id(self) -> str:
return f"ollama/{self.model_name}"
@property
@override
def tokenizer(self) -> EstimatingTokenizer:
return self._tokenizer
@property
@override
def max_tokens(self) -> int:
if "1b" in self.model_name.lower():
return 12288
elif "4b" in self.model_name.lower():
return 16384
elif "8b" in self.model_name.lower():
return 16384
elif "12b" in self.model_name.lower() or "70b" in self.model_name.lower():
return 16384
elif "27b" in self.model_name.lower() or "405b" in self.model_name.lower():
return 32768
else:
return 16384
def _create_options(self, hints: Mapping[str, Any]) -> dict[str, Any]:
"""Create options dict from hints for Ollama."""
options = {}
if "temperature" in hints:
options["temperature"] = hints["temperature"]
if "max_tokens" in hints:
options["num_predict"] = hints["max_tokens"]
if "top_p" in hints:
options["top_p"] = hints["top_p"]
if "top_k" in hints:
options["top_k"] = hints["top_k"]
if "repeat_penalty" in hints:
options["repeat_penalty"] = hints["repeat_penalty"]
options.setdefault("temperature", 0.3)
options.setdefault("top_p", 0.9)
options.setdefault("repeat_penalty", 1.1)
options.setdefault("num_ctx", self.max_tokens)
if "1b" in self.model_name.lower():
options["temperature"] = 0.1
options["top_p"] = 0.5
return options
@policy(
[
retry(
exceptions=(OllamaConnectionError, OllamaTimeoutError, ollama.ResponseError),
max_exceptions=3,
wait_times=(2.0, 4.0, 8.0),
)
]
)
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
with self.logger.scope(f"Ollama LLM Request ({self.schema.__name__})"):
return await self._do_generate(prompt, hints)
async def _do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
timeout = hints.get("timeout", self._default_timeout)
options = self._create_options(hints)
t_start = time.time()
try:
self.logger.debug(f"Sending request to Ollama with timeout={timeout}s")
response = await asyncio.wait_for(
self._client.generate(
model=self.model_name,
prompt=prompt,
format=self.schema.model_json_schema(),
options=options,
stream=False,
),
timeout=timeout,
)
except asyncio.TimeoutError:
elapsed = time.time() - t_start
self.logger.error(f"Ollama request timed out after {elapsed:.1f}s (timeout={timeout}s)")
raise OllamaTimeoutError(
f"Request timed out after {elapsed:.1f}s. Consider increasing timeout or using a smaller model."
)
except ollama.ResponseError as e:
if e.status_code == 404:
raise OllamaModelError(
f"Model {self.model_name} not found. Please pull it first with: ollama pull {self.model_name}"
)
elif e.status_code in [502, 503, 504]:
raise OllamaConnectionError(f"Cannot connect to Ollama server at {self.base_url}")
else:
self.logger.error(f"Ollama API error {e.status_code}: {e.error}")
raise OllamaError(f"API request failed: {e.error}")
except Exception as e:
self.logger.error(f"Unexpected error calling Ollama: {e}")
raise OllamaConnectionError(f"Unexpected error: {e}")
t_end = time.time()
raw_content = response.get("response", "")
if not raw_content:
raise ValueError("No content in response")
json_object = None
try:
normalized = normalize_json_output(raw_content)
json_object = jsonfinder.only_json(normalized)[2]
except Exception:
self.logger.error(
f"Failed to extract JSON returned by {self.model_name}:\n{raw_content}"
)
raise
prompt_eval_count = response.get("prompt_eval_count", 0)
eval_count = response.get("eval_count", 0)
try:
model_content = self.schema.model_validate(json_object)
await record_llm_metrics(
self.meter,
self.model_name,
schema_name=self.schema.__name__,
input_tokens=prompt_eval_count,
output_tokens=eval_count,
)
return SchematicGenerationResult(
content=model_content,
info=GenerationInfo(
schema_name=self.schema.__name__ if hasattr(self, "schema") else "unknown",
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=prompt_eval_count,
output_tokens=eval_count,
),
),
)
except ValidationError as e:
self.logger.error(
f"JSON content from {self.model_name} does not match expected schema. "
f"Validation errors: {e.errors()}"
)
if "1b" in self.model_name.lower():
self.logger.warning(
"The 1B model often struggles with complex schemas. "
"Consider using gemma3:4b or larger for better reliability."
)
raise
class OllamaGemma3_1B(OllamaSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter, base_url: str = "http://localhost:11434"
) -> None:
super().__init__(
model_name="gemma3:1b",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
base_url=base_url,
)
class OllamaGemma3_4B(OllamaSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter, base_url: str = "http://localhost:11434"
) -> None:
super().__init__(
model_name="gemma3:4b",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
base_url=base_url,
)
class OllamaGemma3_12B(OllamaSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter, base_url: str = "http://localhost:11434"
) -> None:
super().__init__(
model_name="gemma3:12b",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
base_url=base_url,
)
class OllamaGemma3_27B(OllamaSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter, base_url: str = "http://localhost:11434"
) -> None:
super().__init__(
model_name="gemma3:27b",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
base_url=base_url,
)
class OllamaLlama31_8B(OllamaSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter, base_url: str = "http://localhost:11434"
) -> None:
super().__init__(
model_name="llama3.1:8b",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
base_url=base_url,
)
class OllamaLlama31_70B(OllamaSchematicGenerator[T]):
"""
@warn: This is a very large model (70B parameters) that requires significant GPU memory.
Recommended for use with cloud providers or high-end hardware only.
Consider using llama3.1:8b or smaller models for local development.
"""
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter, base_url: str = "http://localhost:11434"
) -> None:
super().__init__(
model_name="llama3.1:70b",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
base_url=base_url,
)
class OllamaLlama31_405B(OllamaSchematicGenerator[T]):
"""
@warn: This is an extremely large model (405B parameters) that requires massive GPU memory.
Only suitable for high-end cloud providers with multiple high-memory GPUs.
Not recommended for local use. Consider llama3.1:8b or llama3.1:70b instead.
"""
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter, base_url: str = "http://localhost:11434"
) -> None:
super().__init__(
model_name="llama3.1:405b",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
base_url=base_url,
)
class CustomOllamaSchematicGenerator(OllamaSchematicGenerator[T]):
"""Generic Ollama generator that accepts any model name."""
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
base_url: str = "http://localhost:11434",
) -> None:
super().__init__(
model_name=model_name,
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
base_url=base_url,
)
class OllamaEmbedder(BaseEmbedder):
"""Embedder that uses Ollama embedding models."""
supported_arguments = ["dimensions"]
def __init__(
self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter,
health_reporter: HealthReporter,
) -> None:
super().__init__(
logger=logger,
tracer=tracer,
meter=meter,
health_reporter=health_reporter,
model_name=model_name,
)
self.base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434").rstrip("/")
self._tokenizer = OllamaEstimatingTokenizer(self.model_name)
self._client = ollama.AsyncClient(host=self.base_url)
@property
@override
def id(self) -> str:
return f"ollama/{self.model_name}"
@property
@override
def tokenizer(self) -> EstimatingTokenizer:
return self._tokenizer
@property
@override
def max_tokens(self) -> int:
return 8192
@policy(
[
retry(
exceptions=(OllamaConnectionError, ollama.ResponseError),
max_exceptions=3,
wait_times=(1.0, 2.0, 4.0),
)
]
)
@override
async def do_embed(
self,
texts: list[str],
hints: Mapping[str, Any] = {},
) -> EmbeddingResult:
filtered_hints = {k: v for k, v in hints.items() if k in self.supported_arguments}
try:
response = await self._client.embed(
model=self.model_name, input=texts, **filtered_hints
)
vectors = response.get("embeddings", [])
return EmbeddingResult(vectors=vectors)
except ollama.ResponseError as e:
if e.status_code == 404:
raise OllamaModelError(
f"Embedding model {self.model_name} not found. Please pull it first with: ollama pull {self.model_name}"
)
elif e.status_code in [502, 503, 504]:
raise OllamaConnectionError(f"Cannot connect to Ollama server at {self.base_url}")
else:
raise OllamaError(f"Embedding request failed: {e.error}")
except Exception as e:
self.logger.error(f"Error during embedding: {e}")
raise OllamaConnectionError(f"Unexpected error: {e}")
class OllamaNomicEmbedding(OllamaEmbedder):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="nomic-embed-text", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
@property
@override
def max_tokens(self) -> int:
return 8192
@property
def dimensions(self) -> int:
return 768
class OllamaMxbiEmbeddingLarge(OllamaEmbedder):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="mxbai-embed-large", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
@property
@override
def max_tokens(self) -> int:
return 8192
@property
def dimensions(self) -> int:
return 1024
class OllamaBgeM3EmbeddingLarge(OllamaEmbedder):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="bge-m3", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
@property
@override
def max_tokens(self) -> int:
return 8192
@property
def dimensions(self) -> int:
return 1024
class OllamaCustomEmbedding(OllamaEmbedder):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
self.model_name = os.environ.get("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
self.vector_size = int(os.environ.get("OLLAMA_EMBEDDING_VECTOR_SIZE", "768"))
super().__init__(model_name=self.model_name, logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
@property
@override
def max_tokens(self) -> int:
return 8192
@property
def dimensions(self) -> int:
return self.vector_size
class OllamaService(NLPService):
"""NLP Service that uses Ollama models."""
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
required_vars = {
"OLLAMA_BASE_URL": "http://localhost:11434",
"OLLAMA_MODEL": "gemma3",
"OLLAMA_EMBEDDING_MODEL": "nomic-embed-text",
"OLLAMA_API_TIMEOUT": "300",
}
missing_vars = []
for var_name, default_value in required_vars.items():
if not os.environ.get(var_name):
missing_vars.append(f'export {var_name}="{default_value}"')
if missing_vars:
return f"""\
You're using the Ollama NLP service, but the following environment variables are not set:
{chr(10).join(missing_vars)}
Please set these environment variables before running Parlant.
"""
return None
@staticmethod
def verify_models() -> str | None:
"""
Verify that the required models are available in Ollama.
Returns an error message if models are missing, None if all are available.
"""
base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434").rstrip("/")
embedding_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
generation_model = os.environ.get("OLLAMA_MODEL", "gemma3:4b")
if error := OllamaModelVerifier.verify_models(base_url, generation_model, embedding_model):
return f"Model Verification Issue:\n{error}"
return None
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
self.base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434").rstrip("/")
self.model_name = os.environ.get("OLLAMA_MODEL", "gemma3:4b")
self.embedding_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
self.default_timeout = int(
os.environ.get("OLLAMA_API_TIMEOUT", 300)
) # always convert to int
self.logger = logger
self._tracer = tracer
self._meter = meter
self._health_reporter = health_reporter
self.logger.info(f"Initialized OllamaService with {self.model_name} at {self.base_url}")
@property
@override
def supports_streaming(self) -> bool:
return False
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
raise NotImplementedError("Streaming is not supported. Check supports_streaming first.")
def _get_specialized_generator_class(
self,
model_name: str,
schema_type: type[T],
) -> Callable[..., OllamaSchematicGenerator[T]] | None:
"""
Returns the specialized generator class for known models, or None for custom models.
"""
model_to_class: dict[str, type[OllamaSchematicGenerator[T]]] = {
"gemma3:1b": OllamaGemma3_1B[schema_type], # type: ignore
"gemma3:4b": OllamaGemma3_4B[schema_type], # type: ignore
"gemma3:12b": OllamaGemma3_12B[schema_type], # type: ignore
"gemma3:27b": OllamaGemma3_27B[schema_type], # type: ignore
"llama3.1:8b": OllamaLlama31_8B[schema_type], # type: ignore
"llama3.1:70b": OllamaLlama31_70B[schema_type], # type: ignore
"llama3.1:405b": OllamaLlama31_405B[schema_type], # type: ignore
}
if generator_class := model_to_class.get(model_name):
return generator_class
else:
return None
def _log_model_warnings(self, model_name: str) -> None:
"""Log warnings for resource-intensive models."""
if "70b" in model_name.lower():
self.logger.warning(
f"Using {model_name} - This is a very large model requiring significant GPU memory. "
"Consider using smaller models for local development."
)
elif "405b" in model_name.lower():
self.logger.warning(
f"Using {model_name} - This is an extremely large model requiring massive GPU resources. "
"Only suitable for high-end cloud providers. Consider smaller alternatives."
)
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> SchematicGenerator[T]:
"""Get a schematic generator for the specified type."""
self._log_model_warnings(self.model_name)
specialized_class = self._get_specialized_generator_class(self.model_name, schema_type=t)
if specialized_class:
self.logger.debug(f"Using specialized generator for model: {self.model_name}")
generator = specialized_class(logger=self.logger, base_url=self.base_url)
else:
self.logger.debug(f"Using custom generator for model: {self.model_name}")
generator = CustomOllamaSchematicGenerator[t]( # type: ignore
model_name=self.model_name,
logger=self.logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
base_url=self.base_url,
)
generator._default_timeout = self.default_timeout
return generator
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
if "nomic" in self.embedding_model.lower():
return OllamaNomicEmbedding(self.logger, self._tracer, self._meter, self._health_reporter)
elif "mxbai" in self.embedding_model.lower():
return OllamaMxbiEmbeddingLarge(self.logger, self._tracer, self._meter, self._health_reporter)
elif "bge" in self.embedding_model.lower():
return OllamaBgeM3EmbeddingLarge(self.logger, self._tracer, self._meter, self._health_reporter)
else: # its a custom embedding model
return OllamaCustomEmbedding(self.logger, self._tracer, self._meter, self._health_reporter)
@override
async def get_moderation_service(self) -> ModerationService:
"""Get a moderation service (using no moderation for local models)."""
return NoModeration()
# Model size recommendations
MODEL_RECOMMENDATIONS = {
"gemma3:1b": "Fast but may struggle with complex schemas",
"gemma3:4b": "Recommended for most use cases - good balance of speed and accuracy",
"llama3.1:8b": "Better reasoning capabilities",
"gemma3:12b": "High accuracy for complex tasks",
"gemma3:27b": "Very high accuracy but slower",
"llama3.1:70b": "@warn: Requires significant GPU memory (40GB+)",
"llama3.1:405b": "@warn: Requires massive GPU resources (200GB+), cloud-only",
}
+811
View File
@@ -0,0 +1,811 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from itertools import chain
import re
import time
from openai import (
APIConnectionError,
APIResponseValidationError,
APITimeoutError,
AsyncClient,
ConflictError,
InternalServerError,
RateLimitError,
)
from typing import Any, AsyncIterator, Callable, Mapping
from typing_extensions import override
import json
import jsonfinder # type: ignore
import os
from pydantic import ValidationError
import tiktoken
from parlant.adapters.nlp.common import normalize_json_output, record_llm_metrics
from parlant.core.engines.alpha.canned_response_generator import (
CannedResponseDraftSchema,
CannedResponseSelectionSchema,
)
from parlant.core.engines.alpha.guideline_matching.generic.journey.journey_backtrack_check import (
JourneyBacktrackCheckSchema,
)
from parlant.core.engines.alpha.guideline_matching.generic.journey.journey_backtrack_node_selection import (
JourneyBacktrackNodeSelectionSchema,
)
from parlant.core.engines.alpha.guideline_matching.generic.journey.journey_next_step_selection import (
JourneyNextStepSelectionSchema,
)
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.engines.alpha.tool_calling.single_tool_batch import (
NonConsequentialToolBatchSchema,
SingleToolBatchSchema,
)
from parlant.core.loggers import Logger
from parlant.core.meter import Meter
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.nlp.service import (
EmbedderHints,
ModelSize,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.embedding import BaseEmbedder, Embedder, EmbeddingResult
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
BaseStreamingTextGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.nlp.moderation import (
CustomerModerationContext,
BaseModerationService,
ModerationCheck,
ModerationService,
ModerationTag,
)
from parlant.core.tracer import Tracer
from parlant.core.health import HealthReporter
RATE_LIMIT_ERROR_MESSAGE = (
"OpenAI API rate limit exceeded. Possible reasons:\n"
"1. Your account may have insufficient API credits.\n"
"2. You may be using a free-tier account with limited request capacity.\n"
"3. You might have exceeded the requests-per-minute limit for your account.\n\n"
"Recommended actions:\n"
"- Check your OpenAI account balance and billing status.\n"
"- Review your API usage limits in OpenAI's dashboard.\n"
"- For more details on rate limits and usage tiers, visit:\n"
" https://platform.openai.com/docs/guides/rate-limits/usage-tiers\n"
)
class OpenAIEstimatingTokenizer(EstimatingTokenizer):
def __init__(self, model_name: str) -> None:
self.model_name = model_name
if "5.1" in model_name:
model_name_query = model_name.replace("5.1", "5")
else:
model_name_query = model_name
self.encoding = tiktoken.encoding_for_model(model_name_query)
@override
async def estimate_token_count(self, prompt: str) -> int:
tokens = self.encoding.encode(prompt)
return len(tokens)
class OpenAISchematicGenerator(BaseSchematicGenerator[T]):
supported_openai_params = ["temperature", "logit_bias", "max_tokens"]
supported_hints = supported_openai_params + ["strict"]
unsupported_params_by_model: dict[str, list[str]] = {
"gpt-5": ["temperature"],
}
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
tokenizer_model_name: str | None = None,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._client = AsyncClient(api_key=os.environ["OPENAI_API_KEY"])
self._tokenizer = OpenAIEstimatingTokenizer(
model_name=tokenizer_model_name or self.model_name
)
@property
@override
def id(self) -> str:
return f"openai/{self.model_name}"
@property
@override
def tokenizer(self) -> OpenAIEstimatingTokenizer:
return self._tokenizer
@policy(
[
retry(
exceptions=(
APIConnectionError,
APITimeoutError,
ConflictError,
RateLimitError,
APIResponseValidationError,
),
),
retry(InternalServerError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
with self.logger.scope(f"OpenAI LLM Request ({self.schema.__name__})"):
return await self._do_generate(prompt, hints)
def _list_arguments(self, hints: Mapping[str, Any]) -> Mapping[str, Any]:
exclude_params = [
k
for k in self.supported_openai_params
for prefix, excluded in self.unsupported_params_by_model.items()
if self.model_name.startswith(prefix) and k in excluded
]
return {
k: v
for k, v in hints.items()
if k in self.supported_openai_params and k not in exclude_params
}
async def _do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
openai_api_arguments = self._list_arguments(hints)
if hints.get("strict", False):
t_start = time.time()
try:
response = await self._client.beta.chat.completions.parse(
messages=[{"role": "developer", "content": prompt}],
model=self.model_name,
response_format=self.schema,
**openai_api_arguments,
)
except RateLimitError:
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise
t_end = time.time()
if response.usage:
self.logger.trace(response.usage.model_dump_json(indent=2))
parsed_object = response.choices[0].message.parsed
assert parsed_object
assert response.usage
assert response.usage.prompt_tokens_details
await record_llm_metrics(
self.meter,
self.model_name,
schema_name=self.schema.__name__,
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
cached_input_tokens=response.usage.prompt_tokens_details.cached_tokens or 0,
)
return SchematicGenerationResult[T](
content=parsed_object,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
extra={
"cached_input_tokens": response.usage.prompt_tokens_details.cached_tokens
or 0
},
),
),
)
else:
try:
t_start = time.time()
response = await self._client.chat.completions.create(
messages=[{"role": "developer", "content": prompt}],
model=self.model_name,
response_format={"type": "json_object"},
**openai_api_arguments,
)
t_end = time.time()
except RateLimitError:
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise
if response.usage:
self.logger.trace(response.usage.model_dump_json(indent=2))
raw_content = response.choices[0].message.content or "{}"
try:
json_content = json.loads(normalize_json_output(raw_content))
except json.JSONDecodeError:
self.logger.warning(f"Invalid JSON returned by {self.model_name}:\n{raw_content})")
json_content = jsonfinder.only_json(raw_content)[2]
self.logger.warning("Found JSON content within model response; continuing...")
try:
content = self.schema.model_validate(json_content)
assert response.usage
assert response.usage.prompt_tokens_details
await record_llm_metrics(
self.meter,
self.model_name,
schema_name=self.schema.__name__,
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
cached_input_tokens=response.usage.prompt_tokens_details.cached_tokens or 0,
)
return SchematicGenerationResult(
content=content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
extra={
"cached_input_tokens": response.usage.prompt_tokens_details.cached_tokens
or 0
},
),
),
)
except ValidationError as e:
self.logger.error(
f"Error: {e.json(indent=2)}\nJSON content returned by {self.model_name} does not match expected schema:\n{raw_content}"
)
raise
class GPT_4o(OpenAISchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="gpt-4o-2024-11-20", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class GPT_4o_24_08_06(OpenAISchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="gpt-4o-2024-08-06", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class GPT_4_1(OpenAISchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="gpt-4.1",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
tokenizer_model_name="gpt-4o-2024-11-20",
)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class GPT_4o_Mini(OpenAISchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="gpt-4o-mini", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
self._token_estimator = OpenAIEstimatingTokenizer(model_name=self.model_name)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class GPT_4_1_Mini(OpenAISchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="gpt-4.1-mini", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
self._token_estimator = OpenAIEstimatingTokenizer(model_name=self.model_name)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class GPT_4_1_Nano(OpenAISchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="gpt-4.1-nano", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
self._token_estimator = OpenAIEstimatingTokenizer(model_name=self.model_name)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class GPT_5_1(OpenAISchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="gpt-5.1", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
self._token_estimator = OpenAIEstimatingTokenizer(model_name=self.model_name)
@property
@override
def max_tokens(self) -> int:
return 400_000
class GPT_5_Mini(OpenAISchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="gpt-5-mini", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
self._token_estimator = OpenAIEstimatingTokenizer(model_name=self.model_name)
@property
@override
def max_tokens(self) -> int:
return 400_000
class GPT_5_Nano(OpenAISchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="gpt-5-nano", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
self._token_estimator = OpenAIEstimatingTokenizer(model_name=self.model_name)
@property
@override
def max_tokens(self) -> int:
return 400_000
# ============================================================================
# Streaming Text Generators
# ============================================================================
# Pattern to detect word boundaries for chunking
# Matches after any whitespace character
_WORD_BOUNDARY_PATTERN = re.compile(r"(?<=\s)")
# Number of words to buffer before yielding a chunk
_WORDS_PER_CHUNK = 3
class OpenAIStreamingTextGenerator(BaseStreamingTextGenerator):
"""Streaming text generator using OpenAI's streaming API.
Buffers tokens into word-sized chunks for smoother frontend rendering.
"""
supported_openai_params = ["temperature", "max_tokens"]
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
tokenizer_model_name: str | None = None,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._client = AsyncClient(api_key=os.environ["OPENAI_API_KEY"])
self._tokenizer = OpenAIEstimatingTokenizer(
model_name=tokenizer_model_name or self.model_name
)
@property
@override
def id(self) -> str:
return f"openai-streaming/{self.model_name}"
@property
@override
def tokenizer(self) -> OpenAIEstimatingTokenizer:
return self._tokenizer
def _list_arguments(self, hints: Mapping[str, Any]) -> Mapping[str, Any]:
return {k: v for k, v in hints.items() if k in self.supported_openai_params}
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> tuple[AsyncIterator[str | None], Callable[[], UsageInfo]]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
openai_api_arguments = self._list_arguments(hints)
try:
stream = await self._client.chat.completions.create(
messages=[{"role": "developer", "content": prompt}],
model=self.model_name,
stream=True,
stream_options={"include_usage": True},
**openai_api_arguments,
)
except RateLimitError:
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise
# Track usage from final chunk
usage_info: UsageInfo | None = None
async def chunk_generator() -> AsyncIterator[str | None]:
nonlocal usage_info
# Buffer for accumulating tokens into word-sized chunks
buffer = ""
async for chunk in stream:
# Check for usage in final chunk (when stream_options include_usage is set)
if chunk.usage is not None:
self.logger.trace(chunk.usage.model_dump_json(indent=2))
cached_tokens = 0
if chunk.usage.prompt_tokens_details:
cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens or 0
usage_info = UsageInfo(
input_tokens=chunk.usage.prompt_tokens,
output_tokens=chunk.usage.completion_tokens,
extra={"cached_input_tokens": cached_tokens},
)
if chunk.choices and chunk.choices[0].delta.content:
token = chunk.choices[0].delta.content
buffer += token
# Count word boundaries in buffer
boundaries = list(_WORD_BOUNDARY_PATTERN.finditer(buffer))
if len(boundaries) >= _WORDS_PER_CHUNK:
# Yield up to the last complete word boundary
last_boundary = boundaries[_WORDS_PER_CHUNK - 1]
chunk_text = buffer[: last_boundary.end()]
buffer = buffer[last_boundary.end() :]
yield chunk_text
# Yield any remaining content in the buffer
if buffer:
yield buffer
# Record metrics if we have usage info
if usage_info is not None:
await record_llm_metrics(
self.meter,
self.model_name,
schema_name="streaming",
input_tokens=usage_info.input_tokens,
output_tokens=usage_info.output_tokens,
cached_input_tokens=usage_info.extra.get("cached_input_tokens", 0)
if usage_info.extra
else 0,
)
# Signal completion
yield None
def get_usage() -> UsageInfo:
if usage_info is None:
# Fallback if usage wasn't available
return UsageInfo(input_tokens=0, output_tokens=0)
return usage_info
return chunk_generator(), get_usage
class GPT_4_1_Streaming(OpenAIStreamingTextGenerator):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="gpt-4.1",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
tokenizer_model_name="gpt-4o-2024-11-20",
)
# ============================================================================
# Embedders
# ============================================================================
class OpenAIEmbedder(BaseEmbedder):
supported_arguments = ["dimensions"]
def __init__(self, model_name: str, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(logger, tracer, meter, model_name, health_reporter)
self._client = AsyncClient(api_key=os.environ["OPENAI_API_KEY"])
self._tokenizer = OpenAIEstimatingTokenizer(model_name=self.model_name)
@property
@override
def id(self) -> str:
return f"openai/{self.model_name}"
@property
@override
def tokenizer(self) -> OpenAIEstimatingTokenizer:
return self._tokenizer
@policy(
[
retry(
exceptions=(
APIConnectionError,
APITimeoutError,
ConflictError,
RateLimitError,
APIResponseValidationError,
),
),
retry(InternalServerError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_embed(
self,
texts: list[str],
hints: Mapping[str, Any] = {},
) -> EmbeddingResult:
filtered_hints = {k: v for k, v in hints.items() if k in self.supported_arguments}
try:
response = await self._client.embeddings.create(
model=self.model_name,
input=texts,
**filtered_hints,
)
except RateLimitError:
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise
vectors = [data_point.embedding for data_point in response.data]
return EmbeddingResult(vectors=vectors)
class OpenAITextEmbedding3Large(OpenAIEmbedder):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="text-embedding-3-large", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter
)
@property
@override
def max_tokens(self) -> int:
return 8192
@property
def dimensions(self) -> int:
return 3072
class OpenAITextEmbedding3Small(OpenAIEmbedder):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="text-embedding-3-small", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter
)
@property
@override
def max_tokens(self) -> int:
return 8192
@property
def dimensions(self) -> int:
return 1536
class OpenAIModerationService(BaseModerationService):
def __init__(self, model_name: str, logger: Logger, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(logger, meter, health_reporter)
self.model_name = model_name
self._client = AsyncClient(api_key=os.environ["OPENAI_API_KEY"])
self._hist_moderation_request_duration = meter.create_duration_histogram(
name="moderation",
description="Duration of moderation requests in milliseconds",
)
@override
async def do_moderate(self, context: CustomerModerationContext) -> ModerationCheck:
def extract_tags(category: str) -> list[ModerationTag]:
mapping: dict[str, list[ModerationTag]] = {
"sexual": ["sexual"],
"sexual_minors": ["sexual", "illicit"],
"harassment": ["harassment"],
"harassment_threatening": ["harassment", "illicit"],
"hate": ["hate"],
"hate_threatening": ["hate", "illicit"],
"illicit": ["illicit"],
"illicit_violent": ["illicit", "violence"],
"self_harm": ["self-harm"],
"self_harm_intent": ["self-harm", "violence"],
"self_harm_instructions": ["self-harm", "illicit"],
"violence": ["violence"],
"violence_graphic": ["violence", "harassment"],
}
return mapping.get(category.replace("/", "_").replace("-", "_"), [])
response = await self._client.moderations.create(
input=context.message,
model=self.model_name,
)
result = response.results[0]
return ModerationCheck(
flagged=result.flagged,
tags=list(
set(
chain.from_iterable(
extract_tags(category)
for category, detected in result.categories
if detected
)
)
),
)
class OmniModeration(OpenAIModerationService):
def __init__(self, logger: Logger, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="omni-moderation-latest", logger=logger, meter=meter, health_reporter=health_reporter)
class OpenAIService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
if not os.environ.get("OPENAI_API_KEY"):
return """\
You're using the OpenAI NLP service, but OPENAI_API_KEY is not set.
Please set OPENAI_API_KEY in your environment before running Parlant.
"""
return None
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
self._logger = logger
self._tracer = tracer
self._meter = meter
self._health_reporter = health_reporter
self._logger.info("Initialized OpenAIService")
@property
@override
def supports_streaming(self) -> bool:
return True
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
return GPT_4_1_Streaming(self._logger, self._tracer, self._meter, self._health_reporter)
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> OpenAISchematicGenerator[T]:
match hints.get("model_size", ModelSize.AUTO):
case ModelSize.AUTO:
return {
SingleToolBatchSchema: GPT_4o[SingleToolBatchSchema],
NonConsequentialToolBatchSchema: GPT_4_1[NonConsequentialToolBatchSchema],
JourneyBacktrackNodeSelectionSchema: GPT_4_1[
JourneyBacktrackNodeSelectionSchema
],
CannedResponseDraftSchema: GPT_4_1[CannedResponseDraftSchema],
CannedResponseSelectionSchema: GPT_4_1[CannedResponseSelectionSchema],
JourneyNextStepSelectionSchema: GPT_4_1[JourneyNextStepSelectionSchema],
JourneyBacktrackCheckSchema: GPT_4_1_Mini[JourneyBacktrackCheckSchema],
}.get(t, GPT_4o_24_08_06[t])(self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
case ModelSize.NANO:
match hints.get("model_generation", "auto"):
case "auto" | "stable":
match hints.get("model_type", "auto"):
case "auto" | "standard":
return GPT_4_1_Nano[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
case "reasoning":
return GPT_5_Nano[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
case "latest":
match hints.get("model_type", "auto"):
case "standard":
return GPT_4_1_Nano[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
case "auto" | "reasoning":
return GPT_5_Nano[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
case ModelSize.MINI:
match hints.get("model_generation", "auto"):
case "auto" | "stable":
match hints.get("model_type", "auto"):
case "auto" | "standard":
return GPT_4_1_Mini[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
case "reasoning":
return GPT_5_Mini[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
case "latest":
match hints.get("model_type", "auto"):
case "standard":
return GPT_4_1_Mini[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
case "auto" | "reasoning":
return GPT_5_Mini[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
case _:
match hints.get("model_type", "auto"):
case "reasoning":
return GPT_5_1[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
case _:
return GPT_4o_24_08_06[t](self._logger, self._tracer, self._meter, self._health_reporter) # type: ignore
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
match hints.get("model_size", ModelSize.AUTO):
case ModelSize.AUTO | ModelSize.LARGE:
return OpenAITextEmbedding3Large(self._logger, self._tracer, self._meter, self._health_reporter)
case _:
return OpenAITextEmbedding3Small(self._logger, self._tracer, self._meter, self._health_reporter)
@override
async def get_moderation_service(self) -> ModerationService:
return OmniModeration(self._logger, self._meter, self._health_reporter)
@@ -0,0 +1,683 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import time
from openai import (
APIConnectionError,
APIResponseValidationError,
APITimeoutError,
AsyncClient,
BadRequestError,
ConflictError,
InternalServerError,
RateLimitError,
)
from typing import Any, Callable, Mapping
from typing_extensions import override
import json
import jsonfinder # type: ignore
import os
from pydantic import ValidationError
import tiktoken
from parlant.adapters.nlp.common import normalize_json_output
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.loggers import Logger
from parlant.core.meter import Meter
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.nlp.service import (
EmbedderHints,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.embedding import BaseEmbedder, Embedder, EmbeddingResult
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.nlp.moderation import (
ModerationService,
NoModeration,
)
from parlant.core.tracer import Tracer
from parlant.core.health import HealthReporter
RATE_LIMIT_ERROR_MESSAGE = """\
OpenRouter API rate limit exceeded. Possible reasons:
1. Your account may have insufficient API credits.
2. You may be using a free-tier account with limited request capacity.
3. You might have exceeded the requests-per-minute limit for your account.
Recommended actions:
- Check your OpenRouter account balance and billing status.
- Review your API usage limits in OpenRouter's dashboard.
- For more details on rate limits and usage tiers, visit:
https://openrouter.ai/docs/api-reference/limits
"""
class OpenRouterEmptyEmbeddingResponseError(Exception):
"""Raised when OpenRouter returns an embedding response with no vectors."""
class OpenRouterEstimatingTokenizer(EstimatingTokenizer):
def __init__(self, model_name: str) -> None:
self.model_name = model_name
# Use gpt-4 encoding as default for token estimation
self.encoding = tiktoken.encoding_for_model("gpt-4o-2024-08-06")
@override
async def estimate_token_count(self, prompt: str) -> int:
tokens = self.encoding.encode(prompt)
return len(tokens)
class OpenRouterSchematicGenerator(BaseSchematicGenerator[T]):
supported_openrouter_params = ["temperature", "max_tokens"]
def __init__(self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
super().__init__(logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter, model_name=model_name)
self._logger = logger
# Build extra headers from environment variables
extra_headers = {}
if "OPENROUTER_HTTP_REFERER" in os.environ:
extra_headers["HTTP-Referer"] = os.environ["OPENROUTER_HTTP_REFERER"]
if "OPENROUTER_SITE_NAME" in os.environ:
extra_headers["X-Title"] = os.environ["OPENROUTER_SITE_NAME"]
self._client = AsyncClient(
base_url="https://openrouter.ai/api/v1",
api_key=os.environ["OPENROUTER_API_KEY"],
default_headers=extra_headers if extra_headers else None,
)
self._tokenizer = OpenRouterEstimatingTokenizer(model_name=self.model_name)
@property
@override
def id(self) -> str:
return f"openrouter/{self.model_name}"
@property
@override
def tokenizer(self) -> OpenRouterEstimatingTokenizer:
return self._tokenizer
@property
@override
def max_tokens(self) -> int:
# Default implementation - should be overridden by subclasses
return 8192
@policy(
[
retry(
exceptions=(
APIConnectionError,
APITimeoutError,
ConflictError,
RateLimitError,
APIResponseValidationError,
OpenRouterEmptyEmbeddingResponseError,
),
),
retry(InternalServerError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
openrouter_api_arguments = {
k: v for k, v in hints.items() if k in self.supported_openrouter_params
}
t_start = time.time()
# Try with JSON mode first, but catch errors gracefully
response = None
try:
# Try with JSON mode
response = await self._client.chat.completions.create(
messages=[{"role": "user", "content": prompt}],
model=self.model_name,
response_format={"type": "json_object"},
**openrouter_api_arguments,
)
except BadRequestError as e:
# Check if it's a JSON mode error
error_str = str(e)
if "JSON mode" in error_str or "json_object" in error_str.lower():
self._logger.error(
f"\nModel '{self.model_name}' does not support JSON mode.\n"
f"Please switch to a model that supports JSON mode (e.g., 'openai/gpt-4o', 'anthropic/claude-3.5-sonnet').\n"
f"Attempting to continue without JSON mode enforcement, but results may be less reliable.\n"
)
# Retry without JSON mode with a system message to instruct JSON output
try:
# Add system message to instruct the model to output JSON
json_instruction = "IMPORTANT: You must respond with ONLY valid JSON. No explanatory text before or after the JSON. The response must be a valid JSON object."
response = await self._client.chat.completions.create(
messages=[
{"role": "system", "content": json_instruction},
{"role": "user", "content": prompt},
],
model=self.model_name,
**openrouter_api_arguments,
)
except Exception as retry_error:
self._logger.error(
f"\nFailed to use model '{self.model_name}' even without JSON mode.\n"
f"Error: {retry_error}\n"
f"Please change your model to one that supports JSON mode or use a different model entirely.\n"
)
raise
else:
# Some other BadRequest error - just log it once and raise
self._logger.error(f"OpenRouter API BadRequest: {e}")
raise
except RateLimitError:
self._logger.error(
f"\nRate limit exceeded for model '{self.model_name}'.\n"
f"{RATE_LIMIT_ERROR_MESSAGE}\n"
f"Consider:\n"
f" - Using a different model\n"
f" - Waiting a moment before retrying\n"
f" - Adding your own API key for higher limits\n"
)
raise
except Exception as e:
self._logger.error(
f"\nOpenRouter API error with model '{self.model_name}': {type(e).__name__}\n"
f"{e}\n"
f"Consider switching to a more compatible model.\n"
)
raise
t_end = time.time()
if response.usage:
self._logger.trace(response.usage.model_dump_json(indent=2))
raw_content = response.choices[0].message.content or "{}"
# Check if we got empty response
if not raw_content.strip() or raw_content.strip() == "{}":
self._logger.error(
f"\nModel '{self.model_name}' returned empty or invalid JSON.\n"
f"Response: {raw_content}\n"
f"This model may not be compatible with structured output requirements.\n"
f"Please switch to a model that supports JSON mode (e.g., 'openai/gpt-4o', 'anthropic/claude-3.5-sonnet').\n"
)
# Set empty JSON as fallback
json_content = {}
else:
try:
json_content = json.loads(normalize_json_output(raw_content))
# Check if parsed JSON is empty
if not json_content or json_content == {}:
self._logger.warning(
"Model returned empty JSON object. Attempting to find JSON in response..."
)
# Try to find JSON in the response
try:
json_content = jsonfinder.only_json(raw_content)[2]
if json_content and json_content != {}:
self._logger.info("Found valid JSON content within response.")
except Exception:
self._logger.error(
f"Could not extract valid JSON from response: {raw_content}"
)
except json.JSONDecodeError:
self._logger.warning(f"Invalid JSON returned by {self.model_name}:\n{raw_content}")
try:
# Try to extract JSON using jsonfinder
json_content = jsonfinder.only_json(raw_content)[2]
self._logger.warning("Found JSON content within model response; continuing...")
except Exception as finder_error:
self._logger.error(
f"\nCould not parse JSON from model response.\n"
f"Raw response: {raw_content}\n"
f"Error: {finder_error}\n"
f"Model '{self.model_name}' may not be compatible.\n"
f"Consider switching to a model that supports structured output.\n"
)
json_content = {}
try:
content = self.schema.model_validate(json_content)
assert response.usage
return SchematicGenerationResult(
content=content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
extra={
"cached_input_tokens": getattr(
response.usage,
"prompt_cache_hit_tokens",
0,
)
},
),
),
)
except ValidationError as e:
self._logger.error(
f"\nJSON content returned by '{self.model_name}' does not match expected schema.\n"
f"Schema: {self.schema.__name__}\n"
f"Raw response: {raw_content}\n"
f"Parsed JSON: {json.dumps(json_content, indent=2) if json_content else 'Empty'}\n"
f"Validation errors: {str(e)}\n"
f"This model may not be producing valid structured output.\n"
f"Consider switching to a model that supports JSON mode.\n"
)
raise
class OpenRouterGPT4O(OpenRouterSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="openai/gpt-4o", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class OpenRouterGPT4OMini(OpenRouterSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(model_name="openai/gpt-4o-mini", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class OpenRouterClaude35Sonnet(OpenRouterSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="anthropic/claude-3.5-sonnet", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter
)
@property
@override
def max_tokens(self) -> int:
return 8192
class OpenRouterLlama33_70B(OpenRouterSchematicGenerator[T]):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="meta-llama/llama-3.3-70b-instruct",
logger=logger,
tracer=tracer,
meter=meter, health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 8192
class OpenRouterEmbedder(BaseEmbedder):
supported_arguments = ["dimensions"]
# Known embedding model dimensions
_KNOWN_DIMENSIONS: dict[str, int] = {
"openai/text-embedding-3-large": 3072,
"openai/text-embedding-3-small": 1536,
"openai/text-embedding-ada-002": 1536,
"qwen/qwen3-embedding-8b": 4096,
"qwen/qwen-embedding-v2": 1536,
}
def __init__(self, model_name: str, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(logger, tracer, meter, model_name, health_reporter)
# Build extra headers from environment variables
extra_headers = {}
if "OPENROUTER_HTTP_REFERER" in os.environ:
extra_headers["HTTP-Referer"] = os.environ["OPENROUTER_HTTP_REFERER"]
if "OPENROUTER_SITE_NAME" in os.environ:
extra_headers["X-Title"] = os.environ["OPENROUTER_SITE_NAME"]
self._client = AsyncClient(
base_url="https://openrouter.ai/api/v1",
api_key=os.environ["OPENROUTER_API_KEY"],
default_headers=extra_headers if extra_headers else None,
)
self._tokenizer = OpenRouterEstimatingTokenizer(model_name=self.model_name)
# Cache dimensions after first API call if not known
self._cached_dimensions: int | None = None
@property
@override
def id(self) -> str:
return f"openrouter/{self.model_name}"
@property
@override
def tokenizer(self) -> OpenRouterEstimatingTokenizer:
return self._tokenizer
@property
@override
def max_tokens(self) -> int:
# Default max tokens for embedding models
return 8192
@property
@override
def dimensions(self) -> int:
# Check environment variable override first
if "OPENROUTER_EMBEDDER_DIMENSIONS" in os.environ:
return int(os.environ["OPENROUTER_EMBEDDER_DIMENSIONS"])
# Return cached dimensions if available
if self._cached_dimensions is not None:
return self._cached_dimensions
# Check known dimensions lookup
for model_key, dims in self._KNOWN_DIMENSIONS.items():
if model_key in self.model_name:
return dims
# Default fallback - most embedding models use 1536 or 3072
# This will be updated after first API call
return 1536
@policy(
[
retry(
exceptions=(
APIConnectionError,
APITimeoutError,
ConflictError,
RateLimitError,
APIResponseValidationError,
OpenRouterEmptyEmbeddingResponseError,
),
),
retry(InternalServerError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_embed(
self,
texts: list[str],
hints: Mapping[str, Any] = {},
) -> EmbeddingResult:
filtered_hints = {k: v for k, v in hints.items() if k in self.supported_arguments}
try:
response = await self._client.embeddings.create(
model=self.model_name,
input=texts,
**filtered_hints,
)
except ValueError as exc:
if "No embedding data received" in str(exc):
raise OpenRouterEmptyEmbeddingResponseError(str(exc)) from exc
raise
except RateLimitError:
self.logger.error(
f"\nRate limit exceeded for embedder model '{self.model_name}'.\n"
f"{RATE_LIMIT_ERROR_MESSAGE}\n"
f"Consider:\n"
f" - Using a different embedder model\n"
f" - Waiting a moment before retrying\n"
f" - Adding your own API key for higher limits\n"
)
raise
if not response.data:
raise OpenRouterEmptyEmbeddingResponseError("No embedding data received")
vectors = [data_point.embedding for data_point in response.data]
# Cache dimensions from first response if not already cached and not in known list
if self._cached_dimensions is None and vectors:
actual_dims = len(vectors[0])
# Only cache if different from default or if not found in known dimensions
if actual_dims != 1536 or not any(
key in self.model_name for key in self._KNOWN_DIMENSIONS
):
self._cached_dimensions = actual_dims
self.logger.debug(
f"Detected embedding dimensions for '{self.model_name}': {actual_dims}"
)
return EmbeddingResult(vectors=vectors)
class OpenRouterTextEmbedding3Large(OpenRouterEmbedder):
def __init__(self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter) -> None:
super().__init__(
model_name="openai/text-embedding-3-large", logger=logger, tracer=tracer, meter=meter, health_reporter=health_reporter
)
@property
@override
def max_tokens(self) -> int:
return 8192
@property
@override
def dimensions(self) -> int:
return 3072
class OpenRouterService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
if not os.environ.get("OPENROUTER_API_KEY"):
return """\
You're using the OpenRouter NLP service, but OPENROUTER_API_KEY is not set.
Please set OPENROUTER_API_KEY in your environment before running Parlant.
"""
return None
def __init__(self,
logger: Logger,
tracer: Tracer,
meter: Meter, health_reporter: HealthReporter,
) -> None:
self._logger = logger
self._tracer = tracer
self._meter = meter
self._health_reporter = health_reporter
self._logger.info("Initialized OpenRouterService")
# Get model_name from environment variable
self.model_name = os.environ.get("OPENROUTER_MODEL", "openai/gpt-4o")
# Get embedder_model_name from environment variable
self.embedder_model_name = os.environ.get(
"OPENROUTER_EMBEDDER_MODEL", "openai/text-embedding-3-large"
)
self._logger.info(f"OpenRouter model name: {self.model_name}")
self._logger.info(f"OpenRouter embedder model name: {self.embedder_model_name}")
# Create dynamic embedder class that can be resolved from the container
# This captures embedder_model_name in a closure so the container can resolve it
embedder_model = self.embedder_model_name
class DynamicOpenRouterEmbedder(OpenRouterEmbedder):
def __init__(
self,
logger: Logger,
tracer: Tracer,
meter: Meter,
health_reporter: HealthReporter,
) -> None:
super().__init__(
model_name=embedder_model,
logger=logger,
tracer=tracer,
meter=meter,
health_reporter=health_reporter,
)
self._dynamic_embedder_class = DynamicOpenRouterEmbedder
@property
@override
def supports_streaming(self) -> bool:
return False
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
raise NotImplementedError("Streaming is not supported. Check supports_streaming first.")
def _get_specialized_generator_class(
self,
model_name: str,
t: type[T],
) -> Callable[[Logger, Tracer, Meter, HealthReporter], OpenRouterSchematicGenerator[T]]:
"""
Returns the specialized generator class for known models.
For unknown models, creates a dynamic generator that works with any OpenRouter model.
"""
model_mapping: dict[
str, Callable[[Logger, Tracer, Meter, HealthReporter], OpenRouterSchematicGenerator[T]]
] = {
"openai/gpt-4o": lambda logger, tracer, meter, health_reporter: OpenRouterGPT4O[t]( # type: ignore
logger, tracer, meter, health_reporter
),
"openai/gpt-4o-mini": lambda logger, tracer, meter, health_reporter: OpenRouterGPT4OMini[t]( # type: ignore
logger, tracer, meter, health_reporter
),
"anthropic/claude-3.5-sonnet": lambda logger, tracer, meter, health_reporter: OpenRouterClaude35Sonnet[
t # type: ignore
](logger, tracer, meter, health_reporter),
"meta-llama/llama-3.3-70b-instruct": lambda logger, tracer, meter, health_reporter: (
OpenRouterLlama33_70B[t]( # type: ignore
logger, tracer, meter, health_reporter
)
),
}
# Check if we have a predefined generator for this model
if generator_factory := model_mapping.get(model_name):
return generator_factory
# Create a dynamic generator for any OpenRouter model
# Get max_tokens from environment variable or use sensible defaults based on model name
max_tokens_str = os.environ.get("OPENROUTER_MAX_TOKENS")
if max_tokens_str:
max_tokens = int(max_tokens_str)
else:
# Provide sensible defaults based on model family
if "gpt-4" in model_name:
max_tokens = 128 * 1024
elif "claude" in model_name:
max_tokens = 8192
elif "llama" in model_name or "gemma" in model_name:
max_tokens = 8192
else:
max_tokens = 8192 # Safe default for unknown models
# Create dynamic generator class with the specific max_tokens
final_max_tokens = max_tokens
class DynamicOpenRouterGenerator(OpenRouterSchematicGenerator[T]):
def __init__(
self,
logger: Logger,
tracer: Tracer,
meter: Meter,
health_reporter: HealthReporter,
) -> None:
super().__init__(
model_name=model_name,
logger=logger,
tracer=tracer,
meter=meter,
health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return final_max_tokens
# Return a factory function that creates the properly typed instance
def create_generator(
logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter
) -> OpenRouterSchematicGenerator[T]:
return DynamicOpenRouterGenerator[t](logger, tracer, meter, health_reporter) # type: ignore
return create_generator
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> OpenRouterSchematicGenerator[T]:
generator_factory = self._get_specialized_generator_class(self.model_name, t)
return generator_factory(self._logger, self._tracer, self._meter, self._health_reporter)
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
# Use OpenRouter embedder with the configured embedder model name
# Default to text-embedding-3-large if not specified
if self.embedder_model_name == "openai/text-embedding-3-large":
return OpenRouterTextEmbedding3Large(
logger=self._logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
else:
# Return instance of dynamic embedder class that can be resolved from container
return self._dynamic_embedder_class(
logger=self._logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
@override
async def get_moderation_service(self) -> ModerationService:
return NoModeration()
@@ -0,0 +1,946 @@
# Copyright 2026 Emcie Co Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
from pprint import pformat
import re
import time
from typing import Any, AsyncIterator, Callable, Mapping, TypeAlias, cast
from httpx import AsyncClient
import httpx
from typing_extensions import Literal, override
import json
import jsonfinder # type: ignore
import os
from pydantic import ValidationError
import tiktoken
from parlant.adapters.nlp.common import normalize_json_output, record_llm_metrics
from parlant.core.agents import AgentStore
from parlant.core.application_context import ApplicationContext
from parlant.core.canned_responses import CannedResponseStore
from parlant.core.context_variables import ContextVariableStore
from parlant.core.engines.alpha.prompt_builder import PromptBuilder
from parlant.core.glossary import GlossaryStore
from parlant.core.guidelines import GuidelineStore
from parlant.core.journeys import JourneyStore
from parlant.core.loggers import Logger
from parlant.core.relationships import RelationshipStore
from parlant.core.services.tools.service_registry import ServiceRegistry
from parlant.core.meter import Meter
from parlant.core.nlp.policies import policy, retry
from parlant.core.nlp.tokenization import EstimatingTokenizer
from parlant.core.nlp.service import (
EmbedderHints,
ModelSize,
NLPService,
SchematicGeneratorHints,
StreamingTextGeneratorHints,
)
from parlant.core.nlp.embedding import BaseEmbedder, Embedder, EmbeddingResult
from parlant.core.nlp.generation import (
T,
BaseSchematicGenerator,
BaseStreamingTextGenerator,
SchematicGenerationResult,
StreamingTextGenerator,
)
from parlant.core.nlp.generation_info import GenerationInfo, UsageInfo
from parlant.core.nlp.moderation import (
ModerationService,
NoModeration,
)
from parlant.core.services.indexing.common import ProgressReport
from parlant.core.services.indexing.indexer import IndexRequest, Indexer
from parlant.core.tracer import Tracer
from parlant.core.version import VERSION
from parlant.core.health import HealthReporter
RATE_LIMIT_ERROR_MESSAGE = (
"Parlant Cloud API rate limit exceeded. Possible reasons:\n"
"1. Your account may have insufficient API credits.\n"
"2. You might have exceeded the requests-per-minute limit for your account.\n\n"
"Recommended actions:\n"
"- Check your Parlant Cloud account balance and billing status.\n"
"- Review your API usage limits in Parlant Cloud's dashboard.\n"
"- For more details on rate limits and usage tiers, visit:\n"
" https://parlant.io\n"
)
GenerationModelTier: TypeAlias = Literal["jackal", "bison"]
EmbeddingModelTier: TypeAlias = Literal["jackal-embedding", "bison-embedding"]
ModelRole: TypeAlias = Literal["teacher", "student", "auto"]
BASE_URL = os.environ.get("PARLANT_CLOUD_API_URL", "https://api.parlant.cloud/inference")
# Pattern to detect word boundaries for chunking
# Matches after any whitespace character
_WORD_BOUNDARY_PATTERN = re.compile(r"(?<=\s)")
# Number of words to buffer before yielding a chunk
_WORDS_PER_CHUNK = 3
class ParlantCloudEstimatingTokenizer(EstimatingTokenizer):
def __init__(self) -> None:
self.encoding = tiktoken.encoding_for_model("gpt-4.1")
@override
async def estimate_token_count(self, prompt: str) -> int:
tokens = self.encoding.encode(prompt)
return len(tokens)
class ParlantCloudAPIError(Exception):
pass
class InsufficientCreditsError(ParlantCloudAPIError):
pass
class RateLimitError(ParlantCloudAPIError):
pass
class UnauthorizedError(ParlantCloudAPIError):
pass
def _get_error_detail(response: httpx.Response) -> tuple[str, str]:
try:
error_message = (
response.json().get("detail", {}).get("error", {}).get("message", "Unknown error")
)
request_id = response.json().get("detail", {}).get("request_id", "N/A")
except Exception:
try:
error_message = response.text
except Exception:
error_message = "Unknown error (failed to parse error message)"
request_id = "N/A"
return error_message, request_id
class ParlantCloudSchematicGenerator(BaseSchematicGenerator[T]):
supported_parlant_cloud_params = ["temperature"]
def __init__(
self,
model_name: str,
model_role: ModelRole,
logger: Logger,
tracer: Tracer,
meter: Meter,
health_reporter: HealthReporter,
) -> None:
super().__init__(
logger=logger,
tracer=tracer,
meter=meter,
health_reporter=health_reporter,
model_name=model_name,
)
self._model_role = model_role
self._tokenizer = ParlantCloudEstimatingTokenizer()
@property
@override
def id(self) -> str:
return f"parlant-cloud/{self.model_name}"
@property
@override
def tokenizer(self) -> ParlantCloudEstimatingTokenizer:
return self._tokenizer
@policy(
[
retry(exceptions=(RateLimitError)),
retry(ParlantCloudAPIError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
with self.logger.scope(f"Parlant Cloud LLM Request ({self.schema.__name__})"):
return await self._do_generate(prompt, hints)
async def _do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> SchematicGenerationResult[T]:
if isinstance(prompt, PromptBuilder):
props = prompt.props
prompt = prompt.build()
else:
props = {}
try:
t_start = time.time()
timeout = httpx.Timeout(
connect=30.0,
read=120.0,
write=30.0,
pool=5.0,
)
async with AsyncClient(timeout=timeout) as client:
response = await client.post(
f"{BASE_URL}/v1/completions",
headers={
"Authorization": f"Bearer {os.environ['PARLANT_CLOUD_API_KEY']}",
"X-Parlant-Version": VERSION,
},
json={
"model_tier": self.model_name,
"model_role": self._model_role,
"prompt": prompt,
"schema_name": self.schema.__name__,
"hints": {
k: v
for k, v in hints.items()
if k in self.supported_parlant_cloud_params
},
"payload": props,
},
)
if response.is_error:
error_message, request_id = _get_error_detail(response)
if response.status_code == 429:
raise RateLimitError(
f"Parlant Cloud API rate limit exceeded: {error_message} (RID={request_id})"
)
elif response.status_code == 402:
raise InsufficientCreditsError(
f"Insufficient API credits for Parlant Cloud API: {error_message} (RID={request_id})"
)
elif response.status_code == 403:
raise UnauthorizedError(
f"Unauthorized access to Parlant Cloud API: {error_message} (RID={request_id})"
)
elif response.status_code >= 500:
raise ParlantCloudAPIError(
f"Parlant Cloud API error: {response.status_code} {error_message} (RID={request_id})"
)
response.raise_for_status()
t_end = time.time()
except (InsufficientCreditsError, RateLimitError):
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise
except ParlantCloudAPIError as e:
self.logger.error(f"Parlant Cloud API error occurred: {e}")
raise
except Exception as e:
self.logger.error(f"Unexpected error during Parlant Cloud API call: {e}")
raise
response_data = response.json()
usage = response_data["usage"]
cost = response_data["cost"]
self.logger.trace(f"Parlant Cloud usage data:\n{pformat({**usage, **cost})}")
raw_content = response_data["completion"]
try:
json_content = json.loads(normalize_json_output(raw_content))
except json.JSONDecodeError:
self.logger.warning(f"Invalid JSON returned by {self.model_name}:\n{raw_content})")
json_content = jsonfinder.only_json(raw_content)[2]
self.logger.warning("Found JSON content within model response; continuing...")
try:
content = self.schema.model_validate(json_content)
await record_llm_metrics(
self.meter,
self.model_name,
schema_name=self.schema.__name__,
input_tokens=int(usage["input_tokens"]),
output_tokens=int(usage["output_tokens"]),
cached_input_tokens=0,
)
return SchematicGenerationResult(
content=content,
info=GenerationInfo(
schema_name=self.schema.__name__,
model=self.id,
duration=(t_end - t_start),
usage=UsageInfo(
input_tokens=int(usage["input_tokens"]),
output_tokens=int(usage["output_tokens"]),
extra={},
),
),
)
except ValidationError as e:
self.logger.error(
f"Error: {e.json(indent=2)}\nJSON content returned by {self.model_name} does not match expected schema:\n{raw_content}"
)
raise
class Jackal(ParlantCloudSchematicGenerator[T]):
def __init__(
self,
logger: Logger,
tracer: Tracer,
meter: Meter,
health_reporter: HealthReporter,
model_role: ModelRole,
) -> None:
super().__init__(
model_name="jackal",
logger=logger,
tracer=tracer,
meter=meter,
health_reporter=health_reporter,
model_role=model_role,
)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
class Bison(ParlantCloudSchematicGenerator[T]):
def __init__(
self,
logger: Logger,
tracer: Tracer,
meter: Meter,
health_reporter: HealthReporter,
model_role: ModelRole,
) -> None:
super().__init__(
model_name="bison",
logger=logger,
tracer=tracer,
meter=meter,
health_reporter=health_reporter,
model_role=model_role,
)
@property
@override
def max_tokens(self) -> int:
return 128 * 1024
# ============================================================================
# Streaming Text Generators
# ============================================================================
class ParlantCloudStreamingTextGenerator(BaseStreamingTextGenerator):
"""Streaming text generator using Parlant Cloud's streaming API.
Buffers tokens into word-sized chunks for smoother frontend rendering.
"""
supported_parlant_cloud_params = ["temperature"]
def __init__(
self,
model_name: str,
model_role: ModelRole,
logger: Logger,
tracer: Tracer,
meter: Meter,
health_reporter: HealthReporter,
) -> None:
super().__init__(
logger=logger,
tracer=tracer,
meter=meter,
health_reporter=health_reporter,
model_name=model_name,
)
self._model_role = model_role
self._tokenizer = ParlantCloudEstimatingTokenizer()
@property
@override
def id(self) -> str:
return f"parlant-cloud-streaming/{self.model_name}"
@property
@override
def tokenizer(self) -> ParlantCloudEstimatingTokenizer:
return self._tokenizer
@override
async def do_generate(
self,
prompt: str | PromptBuilder,
hints: Mapping[str, Any] = {},
) -> tuple[AsyncIterator[str | None], Callable[[], UsageInfo]]:
if isinstance(prompt, PromptBuilder):
prompt = prompt.build()
# Track usage from the done event
usage_info: UsageInfo | None = None
async def chunk_generator() -> AsyncIterator[str | None]:
nonlocal usage_info
timeout = httpx.Timeout(
connect=30.0,
read=120.0,
write=30.0,
pool=5.0,
)
# Buffer for accumulating tokens into word-sized chunks
buffer = ""
async with AsyncClient(timeout=timeout) as client:
async with client.stream(
"POST",
f"{BASE_URL}/v1/completions",
headers={
"Authorization": f"Bearer {os.environ['PARLANT_CLOUD_API_KEY']}",
"X-Parlant-Version": VERSION,
},
json={
"model_tier": self.model_name,
"model_role": self._model_role,
"prompt": prompt,
"stream": True,
"hints": {
k: v
for k, v in hints.items()
if k in self.supported_parlant_cloud_params
},
},
) as response:
# Check status before iterating to catch auth/rate-limit errors early
if response.is_error:
await response.aread()
error_message, request_id = _get_error_detail(response)
if response.status_code == 429:
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise RateLimitError(
f"Parlant Cloud API rate limit exceeded: {error_message} (RID={request_id})"
)
elif response.status_code == 402:
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise InsufficientCreditsError(
f"Insufficient API credits for Parlant Cloud API: {error_message} (RID={request_id})"
)
elif response.status_code == 403:
raise UnauthorizedError(
f"Unauthorized access to Parlant Cloud API: {error_message} (RID={request_id})"
)
elif response.status_code >= 500:
raise ParlantCloudAPIError(
f"Parlant Cloud API error: {response.status_code} {error_message} (RID={request_id})"
)
response.raise_for_status()
# Parse SSE events
event_type: str | None = None
async for line in response.aiter_lines():
if line.startswith("event: "):
event_type = line[7:]
elif line.startswith("data: ") and event_type:
data = json.loads(line[6:])
if event_type == "chunk":
text = data.get("text", "")
if text:
buffer += text
# Count word boundaries in buffer
boundaries = list(_WORD_BOUNDARY_PATTERN.finditer(buffer))
if len(boundaries) >= _WORDS_PER_CHUNK:
# Yield up to the last complete word boundary
last_boundary = boundaries[_WORDS_PER_CHUNK - 1]
chunk_text = buffer[: last_boundary.end()]
buffer = buffer[last_boundary.end() :]
yield chunk_text
elif event_type == "done":
usage = data.get("usage", {})
usage_info = UsageInfo(
input_tokens=int(usage.get("input_tokens", 0)),
output_tokens=int(usage.get("output_tokens", 0)),
extra={},
)
self.logger.trace(
f"Parlant Cloud streaming usage data:\n{pformat(data)}"
)
# Yield any remaining content in the buffer
if buffer:
yield buffer
buffer = ""
elif event_type == "error":
error_msg = data.get("error", {}).get("message", "Unknown error")
raise ParlantCloudAPIError(
f"Parlant Cloud streaming error: {error_msg}"
)
# Record metrics if we have usage info
if usage_info is not None:
await record_llm_metrics(
self.meter,
self.model_name,
schema_name="streaming",
input_tokens=usage_info.input_tokens,
output_tokens=usage_info.output_tokens,
cached_input_tokens=0,
)
# Signal completion
yield None
def get_usage() -> UsageInfo:
if usage_info is None:
return UsageInfo(input_tokens=0, output_tokens=0, extra={})
return usage_info
return chunk_generator(), get_usage
class JackalStreaming(ParlantCloudStreamingTextGenerator):
def __init__(
self,
model_role: ModelRole,
logger: Logger,
tracer: Tracer,
meter: Meter,
health_reporter: HealthReporter,
) -> None:
super().__init__(
model_name="jackal",
model_role=model_role,
logger=logger,
tracer=tracer,
meter=meter,
health_reporter=health_reporter,
)
class BisonStreaming(ParlantCloudStreamingTextGenerator):
def __init__(
self,
model_role: ModelRole,
logger: Logger,
tracer: Tracer,
meter: Meter,
health_reporter: HealthReporter,
) -> None:
super().__init__(
model_name="bison",
model_role=model_role,
logger=logger,
tracer=tracer,
meter=meter,
health_reporter=health_reporter,
)
# ============================================================================
# Embedders
# ============================================================================
class ParlantCloudEmbedder(BaseEmbedder):
supported_arguments = ["dimensions"]
def __init__(
self,
model_name: str,
logger: Logger,
tracer: Tracer,
meter: Meter,
health_reporter: HealthReporter,
) -> None:
super().__init__(logger, tracer, meter, model_name, health_reporter)
self._tokenizer = ParlantCloudEstimatingTokenizer()
@property
@override
def id(self) -> str:
return f"parlant-cloud/{self.model_name}"
@property
@override
def tokenizer(self) -> ParlantCloudEstimatingTokenizer:
return self._tokenizer
@policy(
[
retry(exceptions=(RateLimitError)),
retry(ParlantCloudAPIError, max_exceptions=2, wait_times=(1.0, 5.0)),
]
)
@override
async def do_embed(
self,
texts: list[str],
hints: Mapping[str, Any] = {},
) -> EmbeddingResult:
try:
timeout = httpx.Timeout(
connect=5.0,
read=120.0,
write=30.0,
pool=5.0,
)
async with AsyncClient(timeout=timeout) as client:
response = await client.post(
f"{BASE_URL}/v1/embeddings",
headers={
"Authorization": f"Bearer {os.environ['PARLANT_CLOUD_API_KEY']}",
"X-Parlant-Version": VERSION,
},
json={
"model_tier": self.model_name,
"inputs": texts,
"hints": {k: v for k, v in hints.items() if k in self.supported_arguments},
},
)
if response.is_error:
error_message, request_id = _get_error_detail(response)
if response.status_code == 429:
raise RateLimitError(
f"Parlant Cloud API rate limit exceeded: {error_message} (RID={request_id})"
)
elif response.status_code == 402:
raise InsufficientCreditsError(
f"Insufficient API credits for Parlant Cloud API: {error_message} (RID={request_id})"
)
elif response.status_code == 403:
raise UnauthorizedError(
f"Unauthorized access to Parlant Cloud API: {error_message} (RID={request_id})"
)
elif response.status_code >= 500:
raise ParlantCloudAPIError(
f"Parlant Cloud API error: {response.status_code} {error_message} (RID={request_id})"
)
response.raise_for_status()
except (RateLimitError, InsufficientCreditsError):
self.logger.error(RATE_LIMIT_ERROR_MESSAGE)
raise
except Exception as e:
self.logger.error(f"Unexpected error during Parlant Cloud API call: {e}")
raise
response_data = response.json()
vectors = [data_point["embedding"] for data_point in response_data["data"]]
return EmbeddingResult(vectors=vectors)
class BisonEmbedding(ParlantCloudEmbedder):
def __init__(
self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter
) -> None:
super().__init__(
model_name="bison-embedding",
logger=logger,
tracer=tracer,
meter=meter,
health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 8192
@property
def dimensions(self) -> int:
return 3072
class JackalEmbedding(ParlantCloudEmbedder):
def __init__(
self, logger: Logger, tracer: Tracer, meter: Meter, health_reporter: HealthReporter
) -> None:
super().__init__(
model_name="jackal-embedding",
logger=logger,
tracer=tracer,
meter=meter,
health_reporter=health_reporter,
)
@property
@override
def max_tokens(self) -> int:
return 8192
@property
def dimensions(self) -> int:
return 1536
class ParlantCloudIndexer(Indexer):
def __init__(
self,
agent_store: AgentStore,
guideline_store: GuidelineStore,
journey_store: JourneyStore,
relationship_store: RelationshipStore,
glossary_store: GlossaryStore,
context_variable_store: ContextVariableStore,
canned_response_store: CannedResponseStore,
service_registry: ServiceRegistry,
application_context: ApplicationContext,
logger: Logger,
) -> None:
super().__init__(
agent_store=agent_store,
guideline_store=guideline_store,
journey_store=journey_store,
relationship_store=relationship_store,
glossary_store=glossary_store,
context_variable_store=context_variable_store,
canned_response_store=canned_response_store,
service_registry=service_registry,
)
self._application_context = application_context
self._logger = logger
@override
async def index(
self,
payload: Mapping[str, Mapping[str, IndexRequest]],
progress_report: ProgressReport,
) -> None:
serialized_entities: dict[str, dict[str, dict[str, Any]]] = {
category: {
entity_id: {
"type": request.type,
"id": request.id,
"last_modification_utc": request.last_modification_utc.isoformat(),
"checksum": request.checksum,
"data": request.data,
}
for entity_id, request in entities.items()
}
for category, entities in payload.items()
}
body = {
"instance_id": self._application_context.instance_id,
"entities": serialized_entities,
}
timeout = httpx.Timeout(connect=30.0, read=60.0, write=120.0, pool=5.0)
async with AsyncClient(timeout=timeout) as client:
response = await client.post(
f"{BASE_URL}/v1/index",
headers={
"Authorization": f"Bearer {os.environ['PARLANT_CLOUD_API_KEY']}",
"X-Parlant-Version": VERSION,
},
json=body,
)
if response.is_error:
error_message, request_id = _get_error_detail(response)
if response.status_code == 429:
raise RateLimitError(
f"Parlant Cloud API rate limit exceeded: {error_message} (RID={request_id})"
)
elif response.status_code == 402:
raise InsufficientCreditsError(
f"Insufficient API credits for Parlant Cloud API: {error_message} (RID={request_id})"
)
elif response.status_code == 403:
raise UnauthorizedError(
f"Unauthorized access to Parlant Cloud API: {error_message} (RID={request_id})"
)
else:
raise ParlantCloudAPIError(
f"Parlant Cloud API error: {response.status_code} {error_message} (RID={request_id})"
)
status_url = response.json()["status_url"]
if not status_url.startswith("http"):
status_url = f"{BASE_URL}{status_url}"
last_completed = 0
while True:
status_response = await client.get(
status_url,
headers={
"Authorization": f"Bearer {os.environ['PARLANT_CLOUD_API_KEY']}",
"X-Parlant-Version": VERSION,
},
)
if status_response.is_error:
error_message, request_id = _get_error_detail(status_response)
raise ParlantCloudAPIError(
f"Parlant Cloud indexing status error: {status_response.status_code} {error_message} (RID={request_id})"
)
status_data = status_response.json()
completed = int(status_data.get("completed", 0))
delta = completed - last_completed
if delta > 0:
await progress_report.increment(delta)
last_completed = completed
status = status_data.get("status")
if status == "completed":
return
if status == "failed":
raise ParlantCloudAPIError(f"Parlant Cloud indexing failed: {status_data}")
await asyncio.sleep(2)
class ParlantCloudService(NLPService):
@staticmethod
def verify_environment() -> str | None:
"""Returns an error message if the environment is not set up correctly."""
if not os.environ.get("PARLANT_CLOUD_API_KEY"):
return """\
You're using Parlant Cloud's optimized NLP service, but PARLANT_CLOUD_API_KEY is not set.
Please set PARLANT_CLOUD_API_KEY in your environment before running Parlant.
For alternative providers, see https://parlant.io/docs/quickstart/installation.
Get an API key for Parlant Cloud by signing up at https://www.parlant.io."""
return None
def __init__(
self,
logger: Logger,
tracer: Tracer,
meter: Meter,
health_reporter: HealthReporter,
model_tier: GenerationModelTier | None = None,
model_role: ModelRole | None = None,
) -> None:
self._logger = logger
self._tracer = tracer
self._meter = meter
self._health_reporter = health_reporter
self._model_tier = model_tier or os.environ.get("PARLANT_CLOUD_MODEL_TIER", "jackal")
self._model_role = model_role or os.environ.get("PARLANT_CLOUD_MODEL_ROLE", "auto")
assert self._model_tier in ("jackal", "bison"), "Invalid PARLANT_CLOUD_MODEL_TIER"
assert self._model_role in ("teacher", "student", "auto"), (
"Invalid PARLANT_CLOUD_MODEL_ROLE"
)
self._logger.info("Initialized ParlantCloudService")
@property
@override
def supports_streaming(self) -> bool:
return True
@override
async def get_streaming_text_generator(
self, hints: StreamingTextGeneratorHints = {}
) -> StreamingTextGenerator:
match self._model_tier:
case "bison":
return BisonStreaming(
model_role=cast(ModelRole, self._model_role),
logger=self._logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
case _:
return JackalStreaming(
model_role=cast(ModelRole, self._model_role),
logger=self._logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
@override
async def get_schematic_generator(
self, t: type[T], hints: SchematicGeneratorHints = {}
) -> ParlantCloudSchematicGenerator[T]:
match self._model_tier:
case "jackal":
return Jackal[t]( # type: ignore
model_role=cast(ModelRole, self._model_role),
logger=self._logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
case "bison":
return Bison[t]( # type: ignore
model_role=cast(ModelRole, self._model_role),
logger=self._logger,
tracer=self._tracer,
meter=self._meter,
health_reporter=self._health_reporter,
)
case _:
raise ValueError(f"Unsupported model tier: {self._model_tier}")
@override
async def get_embedder(self, hints: EmbedderHints = {}) -> Embedder:
match hints.get("model_size", ModelSize.AUTO):
case ModelSize.AUTO | ModelSize.LARGE:
return BisonEmbedding(
self._logger, self._tracer, self._meter, self._health_reporter
)
case _:
return JackalEmbedding(
self._logger, self._tracer, self._meter, self._health_reporter
)
@override
async def get_moderation_service(self) -> ModerationService:
return NoModeration()

Some files were not shown because too many files have changed in this diff Show More