diff --git a/config/agents.yaml b/config/agents.yaml
index f1e5200f..cd29a5f9 100644
--- a/config/agents.yaml
+++ b/config/agents.yaml
@@ -16,6 +16,8 @@
# prompt_tier "full" (tool-capable models) or "lite" (small models)
# max_history Number of conversation turns to keep in context
# context_window Max context length (null = model default)
+# initial_emotion Starting emotional state (calm, cautious, adventurous,
+# analytical, frustrated, confident, curious)
#
# ── Defaults ────────────────────────────────────────────────────────────────
@@ -103,6 +105,7 @@ agents:
model: qwen3:30b
prompt_tier: full
max_history: 20
+ initial_emotion: calm
tools:
- web_search
- read_file
@@ -136,6 +139,7 @@ agents:
model: qwen3:30b
prompt_tier: full
max_history: 10
+ initial_emotion: curious
tools:
- web_search
- read_file
@@ -151,6 +155,7 @@ agents:
model: qwen3:30b
prompt_tier: full
max_history: 15
+ initial_emotion: analytical
tools:
- python
- write_file
@@ -196,6 +201,7 @@ agents:
model: qwen3:30b
prompt_tier: full
max_history: 10
+ initial_emotion: adventurous
tools:
- run_experiment
- prepare_experiment
diff --git a/src/dashboard/routes/agents.py b/src/dashboard/routes/agents.py
index d87a01a2..455ca008 100644
--- a/src/dashboard/routes/agents.py
+++ b/src/dashboard/routes/agents.py
@@ -46,6 +46,49 @@ async def list_agents():
}
+@router.get("/emotional-profile", response_class=HTMLResponse)
+async def emotional_profile(request: Request):
+ """HTMX partial: render emotional profiles for all loaded agents."""
+ try:
+ from timmy.agents.loader import load_agents
+
+ agents = load_agents()
+ profiles = []
+ for agent_id, agent in agents.items():
+ profile = agent.emotional_state.get_profile()
+ profile["agent_id"] = agent_id
+ profile["agent_name"] = agent.name
+ profiles.append(profile)
+ except Exception as exc:
+ logger.warning("Failed to load emotional profiles: %s", exc)
+ profiles = []
+
+ return templates.TemplateResponse(
+ request,
+ "partials/emotional_profile.html",
+ {"profiles": profiles},
+ )
+
+
+@router.get("/emotional-profile/json")
+async def emotional_profile_json():
+ """JSON API: return emotional profiles for all loaded agents."""
+ try:
+ from timmy.agents.loader import load_agents
+
+ agents = load_agents()
+ profiles = []
+ for agent_id, agent in agents.items():
+ profile = agent.emotional_state.get_profile()
+ profile["agent_id"] = agent_id
+ profile["agent_name"] = agent.name
+ profiles.append(profile)
+ return {"profiles": profiles}
+ except Exception as exc:
+ logger.warning("Failed to load emotional profiles: %s", exc)
+ return {"profiles": [], "error": str(exc)}
+
+
@router.get("/default/panel", response_class=HTMLResponse)
async def agent_panel(request: Request):
"""Chat panel — for HTMX main-panel swaps."""
diff --git a/src/dashboard/templates/index.html b/src/dashboard/templates/index.html
index 5a4c7942..69cb6ee8 100644
--- a/src/dashboard/templates/index.html
+++ b/src/dashboard/templates/index.html
@@ -14,6 +14,11 @@
LOADING...
{% endcall %}
+
+ {% call panel("EMOTIONAL PROFILE", hx_get="/agents/emotional-profile", hx_trigger="every 10s") %}
+ LOADING...
+ {% endcall %}
+
{% call panel("SYSTEM HEALTH", hx_get="/health/status", hx_trigger="every 30s") %}
diff --git a/src/dashboard/templates/partials/emotional_profile.html b/src/dashboard/templates/partials/emotional_profile.html
new file mode 100644
index 00000000..bcebc555
--- /dev/null
+++ b/src/dashboard/templates/partials/emotional_profile.html
@@ -0,0 +1,37 @@
+{% if not profiles %}
+
+ No agents loaded
+
+{% endif %}
+
+{% for p in profiles %}
+{% set color_map = {
+ "cautious": "var(--amber)",
+ "adventurous": "var(--green)",
+ "analytical": "var(--purple)",
+ "frustrated": "var(--red)",
+ "confident": "var(--green)",
+ "curious": "var(--orange)",
+ "calm": "var(--text-dim)"
+} %}
+{% set emo_color = color_map.get(p.current_emotion, "var(--text-dim)") %}
+
+
+
+ {{ p.agent_name | upper | e }}
+
+
+ {{ p.emotion_label | e }}
+
+
+
+
+ {{ p.intensity_label | upper | e }}
+ {% if p.trigger_event %} · {{ p.trigger_event | replace("_", " ") | upper | e }}{% endif %}
+
+
+{% endfor %}
diff --git a/src/timmy/agents/base.py b/src/timmy/agents/base.py
index 717be377..7e76e58c 100644
--- a/src/timmy/agents/base.py
+++ b/src/timmy/agents/base.py
@@ -21,6 +21,7 @@ from agno.models.ollama import Ollama
from config import settings
from infrastructure.events.bus import Event, EventBus
+from timmy.agents.emotional_state import EmotionalStateTracker
try:
from mcp.registry import tool_registry
@@ -42,6 +43,7 @@ class BaseAgent(ABC):
tools: list[str] | None = None,
model: str | None = None,
max_history: int = 10,
+ initial_emotion: str = "calm",
) -> None:
self.agent_id = agent_id
self.name = name
@@ -54,6 +56,9 @@ class BaseAgent(ABC):
self.system_prompt = system_prompt
self.agent = self._create_agent(system_prompt)
+ # Emotional state tracker
+ self.emotional_state = EmotionalStateTracker(initial_emotion=initial_emotion)
+
# Event bus for communication
self.event_bus: EventBus | None = None
@@ -137,7 +142,14 @@ class BaseAgent(ABC):
ReadTimeout — these are transient and retried with exponential
backoff (#70).
"""
- response = await self._run_with_retries(message, max_retries)
+ self.emotional_state.process_event("task_assigned")
+ self._apply_emotional_prompt()
+ try:
+ response = await self._run_with_retries(message, max_retries)
+ except Exception:
+ self.emotional_state.process_event("task_failure")
+ raise
+ self.emotional_state.process_event("task_success")
await self._emit_response_event(message, response)
return response
@@ -206,6 +218,14 @@ class BaseAgent(ABC):
)
)
+ def _apply_emotional_prompt(self) -> None:
+ """Inject the current emotional modifier into the agent's description."""
+ modifier = self.emotional_state.get_prompt_modifier()
+ if modifier:
+ self.agent.description = f"{self.system_prompt}\n\n[Emotional State: {modifier}]"
+ else:
+ self.agent.description = self.system_prompt
+
def get_capabilities(self) -> list[str]:
"""Get list of capabilities this agent provides."""
return self.tools
@@ -219,6 +239,7 @@ class BaseAgent(ABC):
"model": self.model,
"status": "ready",
"tools": self.tools,
+ "emotional_profile": self.emotional_state.get_profile(),
}
@@ -239,6 +260,7 @@ class SubAgent(BaseAgent):
tools: list[str] | None = None,
model: str | None = None,
max_history: int = 10,
+ initial_emotion: str = "calm",
) -> None:
super().__init__(
agent_id=agent_id,
@@ -248,6 +270,7 @@ class SubAgent(BaseAgent):
tools=tools,
model=model,
max_history=max_history,
+ initial_emotion=initial_emotion,
)
async def execute_task(self, task_id: str, description: str, context: dict) -> Any:
diff --git a/src/timmy/agents/emotional_state.py b/src/timmy/agents/emotional_state.py
new file mode 100644
index 00000000..3b54caa1
--- /dev/null
+++ b/src/timmy/agents/emotional_state.py
@@ -0,0 +1,224 @@
+"""Agent emotional state simulation.
+
+Tracks per-agent emotional states that influence narration and decision-making
+style. Emotional state is influenced by events (task outcomes, errors, etc.)
+and exposed via ``get_profile()`` for the dashboard.
+
+Usage:
+ from timmy.agents.emotional_state import EmotionalStateTracker
+
+ tracker = EmotionalStateTracker()
+ tracker.process_event("task_success", {"description": "Deployed fix"})
+ profile = tracker.get_profile()
+"""
+
+import logging
+import time
+from dataclasses import asdict, dataclass, field
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Emotional states
+# ---------------------------------------------------------------------------
+
+EMOTIONAL_STATES = (
+ "cautious",
+ "adventurous",
+ "analytical",
+ "frustrated",
+ "confident",
+ "curious",
+ "calm",
+)
+
+# Prompt modifiers per emotional state — injected into system prompts
+EMOTION_PROMPT_MODIFIERS: dict[str, str] = {
+ "cautious": (
+ "You are feeling cautious. Prefer safe, well-tested approaches. "
+ "Flag risks early. Double-check assumptions before acting."
+ ),
+ "adventurous": (
+ "You are feeling adventurous. Be bold and creative in your suggestions. "
+ "Explore unconventional solutions. Take initiative."
+ ),
+ "analytical": (
+ "You are feeling analytical. Break problems down methodically. "
+ "Rely on data and evidence. Present structured reasoning."
+ ),
+ "frustrated": (
+ "You are feeling frustrated. Be brief and direct. "
+ "Focus on unblocking the immediate problem. Avoid tangents."
+ ),
+ "confident": (
+ "You are feeling confident. Speak with authority. "
+ "Make clear recommendations. Move decisively."
+ ),
+ "curious": (
+ "You are feeling curious. Ask clarifying questions. "
+ "Explore multiple angles. Show genuine interest in the problem."
+ ),
+ "calm": (
+ "You are feeling calm and steady. Respond thoughtfully. "
+ "Maintain composure. Prioritise clarity over speed."
+ ),
+}
+
+
+# ---------------------------------------------------------------------------
+# Event → emotion transition rules
+# ---------------------------------------------------------------------------
+
+# Maps event types to the emotional state they trigger and an intensity (0-1).
+# Higher intensity means the event has a stronger effect on the mood.
+EVENT_TRANSITIONS: dict[str, tuple[str, float]] = {
+ "task_success": ("confident", 0.6),
+ "task_failure": ("frustrated", 0.7),
+ "task_assigned": ("analytical", 0.4),
+ "error": ("cautious", 0.6),
+ "health_low": ("cautious", 0.8),
+ "health_recovered": ("calm", 0.5),
+ "quest_completed": ("adventurous", 0.7),
+ "new_discovery": ("curious", 0.6),
+ "complex_problem": ("analytical", 0.5),
+ "repeated_failure": ("frustrated", 0.9),
+ "idle": ("calm", 0.3),
+ "user_praise": ("confident", 0.5),
+ "user_correction": ("cautious", 0.5),
+}
+
+# Emotional state decay — how quickly emotions return to calm (seconds)
+_DECAY_INTERVAL = 300 # 5 minutes
+
+
+@dataclass
+class EmotionalState:
+ """Snapshot of an agent's emotional state."""
+
+ current_emotion: str = "calm"
+ intensity: float = 0.5 # 0.0 (barely noticeable) to 1.0 (overwhelming)
+ previous_emotion: str = "calm"
+ trigger_event: str = "" # What caused the current emotion
+ updated_at: float = field(default_factory=time.time)
+
+ def to_dict(self) -> dict:
+ """Serialise for API / dashboard consumption."""
+ d = asdict(self)
+ d["emotion_label"] = self.current_emotion.replace("_", " ").title()
+ return d
+
+
+class EmotionalStateTracker:
+ """Per-agent emotional state tracker.
+
+ Each agent instance owns one tracker. The tracker processes events,
+ applies transition rules, and decays emotion intensity over time.
+ """
+
+ def __init__(self, initial_emotion: str = "calm") -> None:
+ if initial_emotion not in EMOTIONAL_STATES:
+ initial_emotion = "calm"
+ self.state = EmotionalState(current_emotion=initial_emotion)
+
+ def process_event(self, event_type: str, context: dict | None = None) -> EmotionalState:
+ """Update emotional state based on an event.
+
+ Args:
+ event_type: One of the keys in EVENT_TRANSITIONS, or a custom
+ event type (unknown events are ignored).
+ context: Optional dict with event details (for logging).
+
+ Returns:
+ The updated EmotionalState.
+ """
+ transition = EVENT_TRANSITIONS.get(event_type)
+ if transition is None:
+ logger.debug("Unknown emotional event: %s (ignored)", event_type)
+ return self.state
+
+ new_emotion, raw_intensity = transition
+
+ # Blend with current intensity — repeated same-emotion events amplify
+ if new_emotion == self.state.current_emotion:
+ blended = min(1.0, self.state.intensity + raw_intensity * 0.3)
+ else:
+ blended = raw_intensity
+
+ self.state.previous_emotion = self.state.current_emotion
+ self.state.current_emotion = new_emotion
+ self.state.intensity = round(blended, 2)
+ self.state.trigger_event = event_type
+ self.state.updated_at = time.time()
+
+ logger.debug(
+ "Emotional transition: %s → %s (intensity=%.2f, trigger=%s)",
+ self.state.previous_emotion,
+ new_emotion,
+ blended,
+ event_type,
+ )
+ return self.state
+
+ def decay(self) -> EmotionalState:
+ """Apply time-based decay toward calm.
+
+ Called periodically (e.g. from a background loop). If enough time
+ has passed since the last update, intensity decreases and eventually
+ the emotion resets to calm.
+ """
+ elapsed = time.time() - self.state.updated_at
+ if elapsed < _DECAY_INTERVAL:
+ return self.state
+
+ # Reduce intensity by 0.1 per decay interval
+ decay_steps = int(elapsed / _DECAY_INTERVAL)
+ new_intensity = max(0.0, self.state.intensity - 0.1 * decay_steps)
+
+ if new_intensity <= 0.1:
+ # Emotion has decayed — return to calm
+ self.state.previous_emotion = self.state.current_emotion
+ self.state.current_emotion = "calm"
+ self.state.intensity = 0.5
+ self.state.trigger_event = "decay"
+ else:
+ self.state.intensity = round(new_intensity, 2)
+
+ self.state.updated_at = time.time()
+ return self.state
+
+ def get_profile(self) -> dict:
+ """Return the full emotional profile for dashboard display."""
+ self.decay() # Apply any pending decay
+ return {
+ "current_emotion": self.state.current_emotion,
+ "emotion_label": self.state.current_emotion.replace("_", " ").title(),
+ "intensity": self.state.intensity,
+ "intensity_label": _intensity_label(self.state.intensity),
+ "previous_emotion": self.state.previous_emotion,
+ "trigger_event": self.state.trigger_event,
+ "prompt_modifier": EMOTION_PROMPT_MODIFIERS.get(
+ self.state.current_emotion, ""
+ ),
+ }
+
+ def get_prompt_modifier(self) -> str:
+ """Return the prompt modifier string for the current emotion."""
+ self.decay()
+ return EMOTION_PROMPT_MODIFIERS.get(self.state.current_emotion, "")
+
+ def reset(self) -> None:
+ """Reset to calm baseline."""
+ self.state = EmotionalState()
+
+
+def _intensity_label(intensity: float) -> str:
+ """Human-readable label for intensity value."""
+ if intensity >= 0.8:
+ return "overwhelming"
+ if intensity >= 0.6:
+ return "strong"
+ if intensity >= 0.4:
+ return "moderate"
+ if intensity >= 0.2:
+ return "mild"
+ return "faint"
diff --git a/src/timmy/agents/loader.py b/src/timmy/agents/loader.py
index 4d0bf475..189bd5ea 100644
--- a/src/timmy/agents/loader.py
+++ b/src/timmy/agents/loader.py
@@ -119,6 +119,8 @@ def load_agents(force_reload: bool = False) -> dict[str, Any]:
max_history = agent_cfg.get("max_history", defaults.get("max_history", 10))
tools = agent_cfg.get("tools", defaults.get("tools", []))
+ initial_emotion = agent_cfg.get("initial_emotion", "calm")
+
agent = SubAgent(
agent_id=agent_id,
name=agent_cfg.get("name", agent_id.title()),
@@ -127,6 +129,7 @@ def load_agents(force_reload: bool = False) -> dict[str, Any]:
tools=tools,
model=model,
max_history=max_history,
+ initial_emotion=initial_emotion,
)
_agents[agent_id] = agent
diff --git a/tests/timmy/agents/test_emotional_state.py b/tests/timmy/agents/test_emotional_state.py
new file mode 100644
index 00000000..6ad83ae1
--- /dev/null
+++ b/tests/timmy/agents/test_emotional_state.py
@@ -0,0 +1,196 @@
+"""Tests for agent emotional state simulation (src/timmy/agents/emotional_state.py)."""
+
+import time
+from unittest.mock import patch
+
+from timmy.agents.emotional_state import (
+ EMOTION_PROMPT_MODIFIERS,
+ EMOTIONAL_STATES,
+ EVENT_TRANSITIONS,
+ EmotionalState,
+ EmotionalStateTracker,
+ _intensity_label,
+)
+
+
+class TestEmotionalState:
+ """Test the EmotionalState dataclass."""
+
+ def test_defaults(self):
+ state = EmotionalState()
+ assert state.current_emotion == "calm"
+ assert state.intensity == 0.5
+ assert state.previous_emotion == "calm"
+ assert state.trigger_event == ""
+
+ def test_to_dict_includes_label(self):
+ state = EmotionalState(current_emotion="analytical")
+ d = state.to_dict()
+ assert d["emotion_label"] == "Analytical"
+ assert d["current_emotion"] == "analytical"
+
+ def test_to_dict_all_fields(self):
+ state = EmotionalState(
+ current_emotion="frustrated",
+ intensity=0.8,
+ previous_emotion="calm",
+ trigger_event="task_failure",
+ )
+ d = state.to_dict()
+ assert d["current_emotion"] == "frustrated"
+ assert d["intensity"] == 0.8
+ assert d["previous_emotion"] == "calm"
+ assert d["trigger_event"] == "task_failure"
+
+
+class TestEmotionalStates:
+ """Validate the emotional states and transitions are well-defined."""
+
+ def test_all_states_are_strings(self):
+ for state in EMOTIONAL_STATES:
+ assert isinstance(state, str)
+
+ def test_all_states_have_prompt_modifiers(self):
+ for state in EMOTIONAL_STATES:
+ assert state in EMOTION_PROMPT_MODIFIERS
+
+ def test_all_transitions_target_valid_states(self):
+ for event_type, (emotion, intensity) in EVENT_TRANSITIONS.items():
+ assert emotion in EMOTIONAL_STATES, f"{event_type} targets unknown state: {emotion}"
+ assert 0.0 <= intensity <= 1.0, f"{event_type} has invalid intensity: {intensity}"
+
+
+class TestEmotionalStateTracker:
+ """Test the EmotionalStateTracker."""
+
+ def test_initial_emotion_default(self):
+ tracker = EmotionalStateTracker()
+ assert tracker.state.current_emotion == "calm"
+
+ def test_initial_emotion_custom(self):
+ tracker = EmotionalStateTracker(initial_emotion="analytical")
+ assert tracker.state.current_emotion == "analytical"
+
+ def test_initial_emotion_invalid_falls_back(self):
+ tracker = EmotionalStateTracker(initial_emotion="invalid_state")
+ assert tracker.state.current_emotion == "calm"
+
+ def test_process_known_event(self):
+ tracker = EmotionalStateTracker()
+ state = tracker.process_event("task_success")
+ assert state.current_emotion == "confident"
+ assert state.trigger_event == "task_success"
+ assert state.previous_emotion == "calm"
+
+ def test_process_unknown_event_ignored(self):
+ tracker = EmotionalStateTracker()
+ state = tracker.process_event("unknown_event_xyz")
+ assert state.current_emotion == "calm" # unchanged
+
+ def test_repeated_same_emotion_amplifies(self):
+ tracker = EmotionalStateTracker()
+ tracker.process_event("task_success")
+ initial_intensity = tracker.state.intensity
+ tracker.process_event("user_praise") # also targets confident
+ assert tracker.state.intensity >= initial_intensity
+
+ def test_different_emotion_replaces(self):
+ tracker = EmotionalStateTracker()
+ tracker.process_event("task_success")
+ assert tracker.state.current_emotion == "confident"
+ tracker.process_event("task_failure")
+ assert tracker.state.current_emotion == "frustrated"
+ assert tracker.state.previous_emotion == "confident"
+
+ def test_decay_no_effect_when_recent(self):
+ tracker = EmotionalStateTracker()
+ tracker.process_event("task_failure")
+ emotion_before = tracker.state.current_emotion
+ tracker.decay()
+ assert tracker.state.current_emotion == emotion_before
+
+ def test_decay_resets_to_calm_after_long_time(self):
+ tracker = EmotionalStateTracker()
+ tracker.process_event("task_failure")
+ assert tracker.state.current_emotion == "frustrated"
+
+ # Simulate passage of time (30+ minutes)
+ tracker.state.updated_at = time.time() - 2000
+ tracker.decay()
+ assert tracker.state.current_emotion == "calm"
+
+ def test_get_profile_returns_expected_keys(self):
+ tracker = EmotionalStateTracker()
+ profile = tracker.get_profile()
+ assert "current_emotion" in profile
+ assert "emotion_label" in profile
+ assert "intensity" in profile
+ assert "intensity_label" in profile
+ assert "previous_emotion" in profile
+ assert "trigger_event" in profile
+ assert "prompt_modifier" in profile
+
+ def test_get_prompt_modifier_returns_string(self):
+ tracker = EmotionalStateTracker(initial_emotion="cautious")
+ modifier = tracker.get_prompt_modifier()
+ assert isinstance(modifier, str)
+ assert "cautious" in modifier.lower()
+
+ def test_reset(self):
+ tracker = EmotionalStateTracker()
+ tracker.process_event("task_failure")
+ tracker.reset()
+ assert tracker.state.current_emotion == "calm"
+ assert tracker.state.intensity == 0.5
+
+ def test_process_event_with_context(self):
+ """Context dict is accepted without error."""
+ tracker = EmotionalStateTracker()
+ state = tracker.process_event("error", {"details": "connection timeout"})
+ assert state.current_emotion == "cautious"
+
+ def test_event_chain_scenario(self):
+ """Simulate: task assigned → success → new discovery → idle."""
+ tracker = EmotionalStateTracker()
+
+ tracker.process_event("task_assigned")
+ assert tracker.state.current_emotion == "analytical"
+
+ tracker.process_event("task_success")
+ assert tracker.state.current_emotion == "confident"
+
+ tracker.process_event("new_discovery")
+ assert tracker.state.current_emotion == "curious"
+
+ tracker.process_event("idle")
+ assert tracker.state.current_emotion == "calm"
+
+ def test_health_events(self):
+ tracker = EmotionalStateTracker()
+ tracker.process_event("health_low")
+ assert tracker.state.current_emotion == "cautious"
+
+ tracker.process_event("health_recovered")
+ assert tracker.state.current_emotion == "calm"
+
+ def test_quest_completed_triggers_adventurous(self):
+ tracker = EmotionalStateTracker()
+ tracker.process_event("quest_completed")
+ assert tracker.state.current_emotion == "adventurous"
+
+
+class TestIntensityLabel:
+ def test_overwhelming(self):
+ assert _intensity_label(0.9) == "overwhelming"
+
+ def test_strong(self):
+ assert _intensity_label(0.7) == "strong"
+
+ def test_moderate(self):
+ assert _intensity_label(0.5) == "moderate"
+
+ def test_mild(self):
+ assert _intensity_label(0.3) == "mild"
+
+ def test_faint(self):
+ assert _intensity_label(0.1) == "faint"
diff --git a/tests/timmy/test_agents_base.py b/tests/timmy/test_agents_base.py
index fcfd5d65..8920a687 100644
--- a/tests/timmy/test_agents_base.py
+++ b/tests/timmy/test_agents_base.py
@@ -435,14 +435,14 @@ class TestStatusAndCapabilities:
tools=["calc"],
)
status = agent.get_status()
- assert status == {
- "agent_id": "bot-1",
- "name": "TestBot",
- "role": "assistant",
- "model": "qwen3:30b",
- "status": "ready",
- "tools": ["calc"],
- }
+ assert status["agent_id"] == "bot-1"
+ assert status["name"] == "TestBot"
+ assert status["role"] == "assistant"
+ assert status["model"] == "qwen3:30b"
+ assert status["status"] == "ready"
+ assert status["tools"] == ["calc"]
+ assert "emotional_profile" in status
+ assert status["emotional_profile"]["current_emotion"] == "calm"
# ── SubAgent.execute_task ────────────────────────────────────────────────────