Adding Emotions to an AI Agent (with Plutchik's Wheel of Emotions)
Architecting Persistent Affect: State-Driven Emotion Vectors for Generative Agents
Current Situation Analysis
The prevailing approach to emotional expression in generative agents relies entirely on static system prompts. Developers inject personality descriptors and speech patterns into the context window, expecting the model to simulate emotional continuity. This method treats emotion as a stylistic overlay rather than a functional state variable.
This approach fails to capture the temporal dynamics of human affect. LLMs do not maintain an internal "emotion register." Research indicates that emotional expression in transformers is context-dependent and moment-to-moment. A study analyzing 25 open-source LLMs (AAAI 2026) demonstrated that personality metrics fluctuate significantly based on question ordering, revealing inherent instability. Further analysis of Claude Sonnet 4.5 (Anthropic 2026) confirmed that emotion concepts are activated reactively based on immediate context rather than being sustained as a coherent internal state.
Consequently, agents exhibit "emotional amnesia." They cannot retain affect across session boundaries, struggle to express mixed emotions (e.g., joy coexisting with anxiety), and lack temporal inertia. When a conversation shifts tone, prompt-based agents reset instantly, breaking immersion and failing to model the residual effects of prior interactions.
WOW Moment: Key Findings
Moving from prompt-based styling to an external state-vector architecture fundamentally changes agent behavior. By decoupling emotion state from the LLM's context window, we enable persistence, concurrency, and decay.
| Dimension | Static Prompt Injection | External State Vector |
|---|---|---|
| Persistence | None (Turn-bound; resets per inference) | High (Persists across turns and sessions) |
| Concurrency | Single label (e.g., "Happy") | Multi-axis superposition (e.g., Joy + Fear) |
| Temporal Dynamics | Instant reset on context change | Exponential decay and emotional inertia |
| Complexity | Discrete categories | Continuous values with adjacency blending |
| Pivot Behavior | Abrupt tonal shifts | Gradual transitions with "mixed" states |
Why this matters: The state-vector approach allows agents to exhibit "emotional hangover." In controlled experiments using a 20-turn script (10 positive turns followed by 10 negative turns), agents with state vectors produced MIXED sentiment classifications at the pivot point. This indicates that residual positive affect persisted numerically, bleeding into responses even as the conversation turned negative. Prompt-only agents switched polarity abruptly, lacking the nuance of overlapping emotional states.
Core Solution
The solution involves implementing a deterministic emotion engine that manages a multi-axis vector based on Plutchik's Wheel of Emotions. This engine operates independently of the LLM, providing a stable state that the agent reads and updates.
1. The Plutchik Vector Model
Plutchik's model defines eight primary emotions arranged in a circular structure:
- Axes: Joy, Trust, Fear, Surprise, Sadness, Disgust, Anger, Anticipation.
- Opposites: Joy β Sadness, Trust β Disgust, Fear β Anger, Surprise β Anticipation.
- Adjacency: Neighboring axes blend to form complex emotions (e.g., Joy + Trust β Love).
Each axis holds a continuous intensity value, typically normalized between 0.0 and 1.0. The meaning of an emotion is derived from its relationship to other axes, not in isolation.
2. Deterministic State Management
The emotion engine must be deterministic. It does not classify emotions; it only stores and manipulates the vector. The LLM retains responsibility for personality and interpretation. This separation ensures that two agents with identical emotion states can express them differently based on their system prompts.
Architecture Rationale:
- External State: Keeps the context window clean and reduces token costs.
- Deterministic Core: Ensures reproducible state transitions and decay.
- LLM as Interpreter: Allows personality to modulate how state translates to output.
3. Implementation Example
The following TypeScript implementation demonstrates a production-ready emotion engine. This differs from CLI-based wrappers by providing a class-based interface suitable for integration into agent frameworks.
export interface AffectVector {
joy: number;
trust: number;
fear: number;
surprise: number;
sadness: number;
disgust: number;
anger: number;
anticipation: number;
}
export interface AffectConfig {
decayRate: number; // Exponential decay constant
maxIntensity: number;
minIntensity: number;
}
export class AffectEngine {
private state: AffectVector;
private lastTimestamp: number;
private config: AffectConfig;
constructor(config: Partial<AffectConfig> = {}) {
this.config = {
decayRate: 0.05,
maxIntensity: 1.0,
minIntensity: 0.0,
...config,
};
this.state = this.createZeroVector();
this.lastTimestamp = Date.now();
}
private createZeroVector(): AffectVector {
return {
joy: 0, trust: 0, fear: 0, surprise: 0,
sadness: 0, disgust: 0, anger: 0, anticipation: 0,
};
}
/**
* Applies a delta to the current state.
* The LLM should generate this delta based on user input and current state.
*/
applyDelta(delta: Partial<AffectVector>): AffectVector {
const now = Date.now();
this.applyDecay(now);
this.lastTimestamp = now;
const newState = { ...this.state };
for (const key of Object.keys(delta) as (keyof AffectVector)[]) {
const val = delta[key] ?? 0;
newState[key] = this.clamp(newState[key] + val);
}
this.state = newState;
return this.getState();
}
/**
* Applies exponential decay based on elapsed time.
* Simulates emotional inertia fading over time.
*/
private applyDecay(now: number): void {
const elapsed = (now - this.lastTimestamp) / 1000; // seconds
if (elapsed <= 0) return;
const decayFactor = Math.exp(-this.config.decayRate * elapsed);
for (const key of Object.keys(this.state) as (keyof AffectVector)[]) {
this.state[key] *= decayFactor;
if (this.state[key] < this.config.minIntensity) {
this.state[key] = 0;
}
}
}
/**
* Returns the current state without modifying it.
* Use this to feed state into the LLM context.
*/
getState(): AffectVector {
return { ...this.state };
}
/**
* Retrieves derived complex emotions based on adjacency.
* Example: Love = min(Joy, Trust).
*/
getDerivedEmotions(): Record<string, number> {
const s = this.state;
return {
love: Math.min(s.joy, s.trust),
submission: Math.min(s.trust, s.fear),
awe: Math.min(s.fear, s.surprise),
disapproval: Math.min(s.surprise, s.sadness),
contempt: Math.min(s.sadness, s.disgust),
aggression: Math.min(s.disgust, s.anger),
annoyance: Math.min(s.anger, s.anticipation),
optimism: Math.min(s.anticipation, s.joy),
};
}
private clamp(value: number): number {
return Math.max(this.config.minIntensity, Math.min(this.config.maxIntensity, value));
}
}
4. Integration Pattern
The agent workflow follows a strict cycle:
- Read: Agent retrieves current state via
getState(). - Reason: LLM generates a response and an emotion delta, considering both user input and current state.
- Update: Agent calls
applyDelta(delta)to persist changes. - Decay:
applyDeltaautomatically handles time-based decay.
This pattern ensures that emotion is a first-class citizen in the agent's loop, not an afterthought.
Pitfall Guide
Implementing state-driven emotion introduces new failure modes. The following pitfalls are derived from production experience with affective agents.
Decay Blind Spot
- Explanation: Developers often update emotion deltas but forget to implement decay. Without decay, emotions accumulate indefinitely, leading to saturation where all axes hit
1.0. - Fix: Always implement exponential decay based on timestamps. Decay should occur on every state access or update.
- Explanation: Developers often update emotion deltas but forget to implement decay. Without decay, emotions accumulate indefinitely, leading to saturation where all axes hit
Personality-State Coupling
- Explanation: Assuming a specific vector always produces the same output. In reality, personality modulates interpretation. A "stoic" agent with high
joymay express it subtly, while an "exuberant" agent expresses it loudly. - Fix: Keep the emotion engine deterministic. Let the system prompt define how vector values map to linguistic style. Test with multiple personalities using the same state.
- Explanation: Assuming a specific vector always produces the same output. In reality, personality modulates interpretation. A "stoic" agent with high
Opposite Neglect
- Explanation: Updating
joywithout consideringsadness. While mixed states are possible, ignoring opposites can lead to unrealistic vectors where contradictory emotions are both at maximum. - Fix: Allow coexistence for nuance, but monitor opposition ratios. Use the vector to detect conflict (e.g., high Joy + high Sadness = bittersweet state) and prompt the LLM accordingly.
- Explanation: Updating
Vector Saturation
- Explanation: Values hitting the ceiling (
1.0) and staying there due to repeated positive updates. This flattens the emotional landscape. - Fix: Enforce hard caps and ensure decay rates are sufficient to pull values down between interactions. Consider implementing a "saturation penalty" where updates have diminishing returns near the max.
- Explanation: Values hitting the ceiling (
Context-Only Updates
- Explanation: The LLM generates emotion deltas based solely on the current user message, ignoring the existing state. This breaks persistence.
- Fix: Include the current state vector in the LLM's context when requesting a delta. Instruct the model to update the state incrementally, not reset it.
Adjacency Ignorance
- Explanation: Treating the eight axes as isolated dimensions. This misses the richness of blended emotions defined by Plutchik's adjacency rules.
- Fix: Implement a derived emotion layer. Calculate complex emotions (e.g., Love, Awe, Contempt) from adjacent axes and feed these derived values to the LLM for richer expression.
Lack of Visualization
- Explanation: Debugging emotion state is difficult without visual feedback. Developers may miss subtle drifts or decay issues.
- Fix: Integrate a visualization tool (e.g., a ring chart or trajectory plot) during development. Monitor state trajectories over time to validate decay and personality effects.
Production Bundle
Action Checklist
- Define the
AffectVectorschema and normalization bounds (0.0 to 1.0). - Implement the
AffectEnginewith decay logic and delta application. - Create a derived emotion layer for adjacency-based complex emotions.
- Integrate the engine into the agent loop: Read State β LLM Reason β Apply Delta.
- Configure decay rates based on desired emotional inertia (test 0.01 to 0.1).
- Add visualization for state monitoring during development.
- Run pivot tests to verify mixed-state generation at tonal shifts.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Simple Chatbot | Prompt-only | Low latency; emotion is stylistic, not functional. | $ |
| RPG / NPC Agent | State Vector | Requires persistence, arcs, and memory of past interactions. | $$ |
| Therapeutic Bot | State Vector | Needs empathy tracking, emotional inertia, and nuanced transitions. | $$ |
| Customer Support | Prompt-only | Efficiency over nuance; emotional drift may confuse users. | $ |
| Creative Writing | State Vector | Benefits from complex emotional blends and trajectory control. | $$ |
Configuration Template
Use this JSON structure to initialize and configure the emotion engine. Adjust decay rates per axis if certain emotions should persist longer.
{
"engine": {
"decayRate": 0.05,
"maxIntensity": 1.0,
"minIntensity": 0.0,
"axisDecayModifiers": {
"fear": 0.8,
"anger": 0.9,
"joy": 1.0
}
},
"derivedEmotions": {
"love": ["joy", "trust"],
"submission": ["trust", "fear"],
"awe": ["fear", "surprise"],
"disapproval": ["surprise", "sadness"],
"contempt": ["sadness", "disgust"],
"aggression": ["disgust", "anger"],
"annoyance": ["anger", "anticipation"],
"optimism": ["anticipation", "joy"]
}
}
Quick Start Guide
- Initialize: Create an instance of
AffectEnginewith default or custom config. - Read State: Call
engine.getState()and inject the vector into the LLM system prompt. - Process Input: Pass user message and current state to the LLM. Request a response and an emotion delta.
- Update State: Parse the delta and call
engine.applyDelta(delta). - Monitor: Log state changes and visualize trajectories to validate behavior.
This architecture transforms emotion from a static prompt artifact into a dynamic, persistent state variable. By leveraging Plutchik's structural model and deterministic state management, agents gain the temporal continuity and nuance required for sophisticated character simulation.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
