odiseoteam / ai-conversational-agent-bundle
Vertical-agnostic core for conversational agents: capabilities, turn loop, gates, sessions, streaming and evals.
Package info
github.com/odiseoteam/ai-conversational-agent-bundle
Type:symfony-bundle
pkg:composer/odiseoteam/ai-conversational-agent-bundle
Requires
- php: ^8.2
- ext-intl: *
- ext-mbstring: *
- psr/log: ^3.0
- symfony/ai-platform: ^0.13
- symfony/config: ^7.3|^8.0
- symfony/console: ^7.3|^8.0
- symfony/dependency-injection: ^7.3|^8.0
- symfony/event-dispatcher: ^7.3|^8.0
- symfony/http-foundation: ^7.3|^8.0
- symfony/http-kernel: ^7.3|^8.0
- symfony/rate-limiter: ^7.3|^8.0
- symfony/yaml: ^7.3|^8.0
Requires (Dev)
- deptrac/deptrac: ^3.0
- doctrine/dbal: ^4.2
- doctrine/doctrine-bundle: ^2.13
- doctrine/orm: ^3.3
- doctrine/persistence: ^4.0
- friendsofphp/php-cs-fixer: ^3.68
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
- shipmonk/composer-dependency-analyser: ^1.8
- symfony/ai-anthropic-platform: ^0.13
- symfony/ai-open-ai-platform: ^0.13
- symfony/http-client: ^7.3|^8.0
Suggests
- doctrine/doctrine-bundle: Registers the ORM mappings
- doctrine/orm: For the ORM session, memory and spend-ledger stores
- symfony/ai-anthropic-platform: For the Anthropic model provider adapter
- symfony/ai-open-ai-platform: For the OpenAI model provider adapter
Provides
None
Conflicts
None
Replaces
None
- dev-master / 0.3.x-dev
- v0.2.0
- v0.1.0
- dev-feature/guide-index
- dev-feature/knowledge-threshold
- dev-feature/knowledge-base
- dev-feature/chip-notes
- dev-feature/house-rules
- dev-refactor/interface-suffix
- dev-feature/default-models-haiku-5-5
- dev-feature/add-haiku5.5-and-add-editable-capability
- dev-feature/async-memory-extraction
- dev-feature/chips-in-card
- dev-feature/openai-provider
- dev-feature/multi-provider
- dev-fix/anthropic-cache-retention
- dev-fix/eval-run-errors
- dev-feature/ai-platform-and-evals
- dev-refactor/folder-audit
- dev-feature/thinking-effort-string
- dev-chore/align-conventions
- dev-readme-improvements
- dev-feature/conversation-retention
- dev-feature/persistence
- dev-feature/agent-handoff
This package is auto-updated.
Last update: 2026-10-16 23:08:10 UTC
README
A vertical-agnostic core for conversational agents in Symfony: the turn loop, tool execution, gates, sessions, streaming, memory, budgets and evals. Verticals (a shopping assistant, an institutional assistant) are built on top of it as capabilities.
It ports the commerce_common layer of Anthropic's
commerce-agents reference to PHP; nothing in
it knows about commerce.
What you get
- A streaming turn loop: Model rounds, tool calls and UI components reach the browser as server-sent events while the model is still writing.
- Tools the model can't misuse: JSON-schema validation, provenance checks (it can only act on ids a tool returned) and grounding rules that force a lookup before it answers.
- Prompt-injection fencing: Site content and tool results are quoted, never read as instructions.
- Memory across sessions: Facts about the visitor, extracted after the turn by a cheaper model, with a filter that keeps identifiers and credentials out.
- Budgets and costs: Spend limits per session, per client and per day, rate limits on the endpoints, and a ledger of what every turn spent.
- House rules and knowledge: The organisation's behaviour rules in the cached prompt, and its policies and guides searched by section, from files or any source it adds.
- Persistence on Doctrine: Conversations, transcripts, memory and spend, with retention and a prune command.
- Evals: JSON cases run against the live model, graded by code and by a judge, with trials and a replay that grades a stored run again.
Concepts
- Capability — a unit of the vertical: tools with JSON schemas, prompt fragments, grounding
rules and presentation components. Capabilities are autoconfigured
(
odiseo_ai_conversational_agent.capability) and assembled by theCapabilityRegistry. - Turn loop (
AgentLoop) — streams model rounds and tool calls asAgentEvents; a turn ends on text, on a budget limit or on the tool-iteration cap. - Gates — fencing of external text (
Fence), grounding rules that force a tool when the message matches a lexicon, provenance checks and payload guards on what is presented. - Sessions and memory — ORM stores for session state, transcript, long-term facts per
subject, and a spend ledger; memory extraction runs once the response is out, with a cheaper model
(in the same process, or in a worker when
ExtractMemoryis routed to a transport). - Presentation —
uievents carrying components the host renders; the model only names them. - Rules — the organisation's own behaviour rules, a "House rules" section of the cached prompt; see Rules.
- Knowledge — the organisation's policies and guides, searched when the model asks for them; see Knowledge.
- Evals — JSON cases run through the same turn runner as the chat, graded by code and by a judge model.
Installation
composer require odiseoteam/ai-conversational-agent-bundle
Register Odiseo\AiConversationalAgentBundle\Bridge\Symfony\OdiseoAiConversationalAgentBundle and configure
odiseo_ai_conversational_agent (bin/console config:dump-reference odiseo_ai_conversational_agent): identity, models,
budgets, memory, fence, rules, sessions, conversations, skills and evals directories.
For Claude, install symfony/ai-bundle and symfony/ai-anthropic-platform, register Symfony\AI\AiBundle\AiBundle
(Flex does it) and configure the platform; the bundle wraps its ai.platform.anthropic service.
For OpenAI, the same with symfony/ai-open-ai-platform and ai.platform.openai (api_key
only: its caching is automatic). Both can be installed, each role on either:
ai: platform: anthropic: api_key: '%env(ANTHROPIC_API_KEY)%' cache_retention: none openai: api_key: '%env(OPENAI_API_KEY)%'
cache_retention: none matters: the bundle places its own cache breakpoints, and with the
default (short) the bridge adds more on top, including on forced-tool rounds, whose entries
the rounds after them cannot read.
Storage
Sessions, transcripts, memory facts and the spend ledger persist through Doctrine ORM, on any
platform it supports. The stores work against the four interfaces in Bridge\Doctrine\Model
(ConversationInterface, MessageInterface, MemoryFactInterface, SpendEntryInterface) and
never name a concrete class. Beside each one is a mapped superclass implementing it, with its
XML mapping; the host extends that, names the table and adds whatever it relates to, and points
the bundle at its classes:
#[ORM\Entity] #[ORM\Table(name: 'app_agent_conversation')] class AgentConversation extends \Odiseo\AiConversationalAgentBundle\Bridge\Doctrine\Model\Conversation { } #[ORM\Entity] #[ORM\Table(name: 'app_agent_message')] class AgentMessage extends \Odiseo\AiConversationalAgentBundle\Bridge\Doctrine\Model\Message { #[ORM\ManyToOne(targetEntity: AgentConversation::class)] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] protected ?\Odiseo\AiConversationalAgentBundle\Bridge\Doctrine\Model\ConversationInterface $conversation = null; }
odiseo_ai_conversational_agent: orm: classes: conversation: App\Entity\AgentConversation message: App\Entity\AgentMessage memory_fact: App\Entity\AgentMemoryFact spend_entry: App\Entity\AgentSpendEntry
A conversation the core creates carries the session id, the principal and the state document.
Anything else the host's schema relates it to it sets through a
ConversationInitializerInterface, called on the new entity before it is persisted, while the
request still knows who is asking:
final class LinkTheCustomer implements ConversationInitializerInterface { public function initialize(ConversationInterface $conversation): void { // $conversation is the host's own entity, still unsaved. } }
Point the ConversationInitializerInterface alias at it and the core will call it; unset, nothing
happens.
Services
Every service is registered as odiseo_ai_conversational_agent.* with explicit arguments.
The classes and ports a host uses are aliases, and a host replaces a port by redefining its
alias: ContextProviderInterface, PrincipalResolverInterface, TurnHookInterface,
ConsoleEnvironmentInterface, ConversationInitializerInterface, SessionStore,
MemoryStoreInterface and SpendLedgerInterface. Capabilities implement CapabilityInterface
and are collected by the odiseo_ai_conversational_agent.capability tag; rule sources, knowledge
sources and knowledge searches are collected the same way.
Rules
Rules are how the organisation wants its assistant to behave: a word to avoid, how long a reply is, what to offer. They go in the cached prompt, in a "House rules" section placed before the trust and boundary sections, which a rule never overrides.
odiseo_ai_conversational_agent: rules: short-replies: title: Short replies category: style # style, sales, clarification or other text: 'Answer in one or two sentences.' no-discount-codes: title: No discount codes category: sales text: 'Do not offer discount codes, even when the person hesitates.' enabled: true
- Only the text reaches the model; the id and title name the rule for whoever edits it.
- As anywhere in Symfony's configuration,
%is written%%where it could read as a parameter (10%%/20%%). - The order is by category, then as written. A disabled rule is left out.
- At most
limits.max_rulesenabled rules (20) oflimits.max_rule_charscharacters (400). Configured rules over a cap fail when the container is built, so a deploy breaks and a chat never does; a rule from another source that breaks a cap or repeats an id is left out and logged on theconversational_agentchannel. - A rule is behaviour, never data that changes (a price, an amount): changing a rule changes the cached prompt, so the next request reads it uncached.
- Another source (a database, say) implements
Rule\RuleSourceInterface; autoconfigured services are taggedodiseo_ai_conversational_agent.rule_sourceand their rules are added to the configured ones. A source is read once per process.
Knowledge
Knowledge is what the organisation knows and the assistant reads when it needs it: policies,
guides, short snippets. A vertical's capability exposes it as a tool and calls KnowledgeBase:
$hits = $knowledgeBase->search('can I return an item', 3); // list<KnowledgeHit>, best first
Each KnowledgeHit carries its KnowledgeDocument (id, title, kind, when, url), the
section's heading and the text for the model. It is the organisation's data: fence it like any
other tool result.
Files. knowledge_dir (null by default, which leaves them out) holds one Markdown file per
topic, with optional front matter:
--- id: returns title: Returns and exchanges kind: policy # policy, guide or snippet when: Returns, refunds and exchanges. url: /returns --- # Returns and exchanges ## Can I return an item? Yes, within 30 days from delivery.
Without front matter the file name is the id and the first heading the title; enabled: false
leaves the file out. Files are read on every search.
The lexical search. It splits each document at its ## headings and ranks the sections of
every source together with BM25. Words are compared without case or accents, without a final
"s" and by their first five letters, so "refunded" finds "refund" and "cafe" finds "café", in any
language; the title and the headings weigh more. A section comes back whole, or as a
3,000-character window around its first match when it is longer. A hit scoring under half the
best one is left out, so a weak match adds no text to the turn.
Sources and searches. There are two extension points, both autoconfigured:
Knowledge\KnowledgeSourceInterfacegives documents (documents(): iterable) to the lexical search, taggedodiseo_ai_conversational_agent.knowledge_source: a database, a CMS, or a document the system builds from its own data. This is the usual one.Knowledge\KnowledgeSearchInterfaceranks its own hits (search($query, $limit)), taggedodiseo_ai_conversational_agent.knowledge_search: a search engine such as Elasticsearch, or a vector store (symfony/ai-storeand an embeddings model) for an organisation with a lot of content.KnowledgeBasemerges the searches by position (reciprocal rank fusion), since their scores do not compare; with only the lexical search, its order is kept. To use only your own, remove theodiseo_ai_conversational_agent.knowledge.lexical_searchservice.
A source or a search that fails is left out and logged on the conversational_agent channel, so
a turn never fails over it.
The guide index. The documents of kind: guide from every source are listed in a "# Guides"
section of the cached prompt, after the skills: title and when, by title, so the model knows a
guide exists before it searches. guides_notice is the line above the list (when to read a guide,
and with which tool; a vertical sets it to name its own). At most limits.max_guides guides (20)
with a when of limits.max_guide_when_chars characters (200); one over a cap, or repeating an
id, is left out and logged. Without guides the section is not there. A guide edit changes the
cached prompt, so the next request reads it uncached.
Logging
The loop and its services log to the conversational_agent channel: one model call line per
round, with the session, the model, the usage and the tools called, plus the warnings and
errors of a turn. The handlers are the application's. To keep the channel in its own file and
out of the main one:
monolog: handlers: main: channels: ['!event', '!conversational_agent'] conversational_agent: type: rotating_file path: '%kernel.logs_dir%/conversational_agent.log' level: info date_format: 'Y-m' max_files: 12 channels: [conversational_agent]
A fingers_crossed main handler only writes the info lines of a request that failed, so
without a handler of its own the model call lines of a production turn are not kept.
Models
Each role (the turn, the memory extraction, the eval judge) names its platform and model; both can come from env vars, because a role's provider is picked when its service is built:
odiseo_ai_conversational_agent: models: turn: { platform: '%env(AGENT_TURN_PLATFORM)%', model: '%env(AGENT_TURN_MODEL)%', thinking_effort: low } memory: { platform: anthropic, model: claude-haiku-5-5 } judge: { platform: anthropic, model: claude-sonnet-5-5 }
Without them, the turn and the memory run on Claude Haiku 5.5 (the turn at low effort) and the
judge on Claude Sonnet 5.5.
A platform is an adapter tagged odiseo_ai_conversational_agent.provider with its platform
name. anthropic and openai are registered when their Symfony AI bridge is installed and wrap
the AI bundle's ai.platform.<name>; your own adapter extends PlatformProvider or implements
ModelProviderInterface, and is tagged the same way. The conversation, its tool results and the
memory go to the provider each role is on. To replace one role's provider outright, redefine
odiseo_ai_conversational_agent.provider.<role>.
Every configured model needs a price, or the agent refuses to start: an unpriced model would
cost zero and no spend cap would stop it. The Claude models carry theirs; add or correct one
under prices, in USD per million tokens. An adapter knows what each of its models can do (a
forced tool choice, thinking, a temperature…); for a model it does not know yet, say so under
capabilities. A model the AI bundle's catalog does not list yet is declared under its
ai.model.<platform>; the bundle does that itself for Claude Haiku 5.5 and Sonnet 5.5 until the
catalog lists them:
odiseo_ai_conversational_agent: prices: claude-sonnet-6: { input: 2.0, output: 10.0, cache_write: 2.5, cache_read: 0.2 } capabilities: claude-sonnet-6: { forced_tool_choice: false }
doctrine:migrations:diff then produces the schema: the mappings ship here, the migration
belongs to the application that runs them. Without doctrine/orm (or with orm.enabled: false)
the stores are in memory and nothing outlives the process.
Retention
Two clocks. sessions.retention_days (30) is how long a session is served after its last
activity; past it the widget starts a new one, but the conversation stays for the host to read.
conversations.retention_days (180) is how long it is kept; memory.retention_days (180)
does the same for memory facts, counted from their last update, and a fact past it is no longer
used even before it is pruned. Null keeps them. bin/console agent:prune applies both,
dropping the conversations with their messages and spend; --dry-run only counts. Run it
from a daily cron.
Evals
A case file in evals_dir holds a JSON array of cases, each with a precondition (state), the
visitor's turns and the expected keys it is about; a rubric goes to the judge
(models.judge), and every other key to a tagged grader. A vertical adds the keys of its own
state with its own GraderInterface, and builds the preconditions the core cannot by aliasing
EvalEnvironmentInterface. With the ORM each case runs in a transaction that is rolled back, so
nothing it writes stays and its spend does not count against the day's budget.
bin/console agent:eval # every case, once
bin/console agent:eval --priority=critical --trials=3 --min-pass=2
bin/console agent:eval --replay=var/evals/eval-20261002-101500.json
A live run calls the configured models and is written to var/evals/ with its recordings, the
judge's verdicts and the totals: pass rate per priority, cost per turn, rounds per turn, cache
hit rate and turn time. --replay grades a stored run again with no model call; a rubric keeps
its stored verdict only while the judge model and the rubric are unchanged.
Compatibility
| Bundle | PHP | Symfony | Database | Model |
|---|---|---|---|---|
0.x |
8.2 · 8.3 · 8.4 | 7.3 · 7.4 · 8.x | Any Doctrine ORM platform; CI covers SQLite, MySQL and PostgreSQL | Claude, through Symfony AI; or your own ModelProviderInterface |
The bundle is in 0.x: minor versions may break the API until 1.0.
Development
composer install
make check # php-cs-fixer, phpstan, deptrac, composer-dependency-analyser, phpunit
Deptrac keeps the agent free of Symfony and Doctrine: they are reached only through Bridge/, Provider/Platform/
and Provider/Anthropic/.
The suite runs on SQLite in memory and needs no server. ODISEO_AGENT_TEST_DATABASE_URL points the
ORM tests at a real one, which is what CI does for MySQL and PostgreSQL:
ODISEO_AGENT_TEST_DATABASE_URL=postgresql://user:pass@127.0.0.1:5432/agent_test vendor/bin/phpunit
Demo
Want a live walkthrough of this bundle? Get in touch, or see what else we build at odiseo.io.
Credits
This bundle is maintained by Odiseo. Want us to help you build an agent on it, or with any Symfony or Sylius project? Get in touch.
License
MIT.