Spaces:
Running
Running
| """ | |
| Genome Engine — Agent personality core extracted from genome_v4.py. | |
| Provides: | |
| - Agent: Random neural network personality entity with drives, | |
| Hebbian learning, frustration-triggered phase transitions. | |
| - DRIVES, SIGNALS, CONTEXT_FEATURES: Constants for the 5-drive, | |
| 8-signal, 8-context architecture. | |
| - SCENARIOS: Predefined conversation context templates. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import random | |
| import json | |
| from copy import deepcopy | |
| from typing import Optional | |
| # ══════════════════════════════════════════════ | |
| # Layer 1: Drive System | |
| # ══════════════════════════════════════════════ | |
| DRIVES = ['connection', 'novelty', 'expression', 'safety', 'play'] | |
| DRIVE_LABELS = { | |
| 'connection': '🔗 联结', | |
| 'novelty': '✨ 新鲜', | |
| 'expression': '💬 表达', | |
| 'safety': '🛡️ 安全', | |
| 'play': '🎭 玩闹', | |
| } | |
| N_DRIVES = len(DRIVES) | |
| # ══════════════════════════════════════════════ | |
| # Layer 2: Behavioral Modulation Signals (8D) | |
| # ══════════════════════════════════════════════ | |
| SIGNALS = [ | |
| 'directness', # 0=委婉暗示 → 1=直说 | |
| 'vulnerability', # 0=防御心理 → 1=袒露脆弱 | |
| 'playfulness', # 0=认真严肃 → 1=玩闹撒娇 | |
| 'initiative', # 0=被动回应 → 1=主动引导 | |
| 'depth', # 0=表面闲聊 → 1=深度对话 | |
| 'warmth', # 0=冷淡疏离 → 1=热情关怀 | |
| 'defiance', # 0=顺从 → 1=反抗/嘴硬 | |
| 'curiosity', # 0=无所谓 → 1=追问到底 | |
| ] | |
| SIGNAL_LABELS = { | |
| 'directness': '🎯 直接度', | |
| 'vulnerability': '💧 坦露度', | |
| 'playfulness': '🎪 玩闹度', | |
| 'initiative': '🚀 主动度', | |
| 'depth': '🌊 深度', | |
| 'warmth': '🔥 温暖度', | |
| 'defiance': '⚡ 倔强度', | |
| 'curiosity': '🔍 好奇度', | |
| } | |
| N_SIGNALS = len(SIGNALS) | |
| # ══════════════════════════════════════════════ | |
| # Context Features (8D input from conversation) | |
| # ══════════════════════════════════════════════ | |
| CONTEXT_FEATURES = [ | |
| 'user_emotion', # -1=负面 → 1=正面 | |
| 'topic_intimacy', # 0=公事 → 1=私密 | |
| 'time_of_day', # 0=早晨 → 1=深夜 | |
| 'conversation_depth', # 0=刚开始 → 1=聊很久了 | |
| 'user_engagement', # 0=敷衍 → 1=投入 | |
| 'conflict_level', # 0=和谐 → 1=冲突 | |
| 'novelty_level', # 0=日常话题 → 1=全新话题 | |
| 'user_vulnerability', # 0=防御 → 1=敞开心扉 | |
| # ── EverMemOS relationship dimensions (新用户“0,老用户渐进增长) ── | |
| 'relationship_depth', # 0=陈生人 → 1=老朋友 | |
| 'emotional_valence', # -1=负面基调 → 1=正面基调 | |
| 'trust_level', # 0=无信任 → 1=高度信任 | |
| 'pending_foresight', # 0=无 → 1=有待处理的前瞅 | |
| ] | |
| N_CONTEXT = len(CONTEXT_FEATURES) | |
| RECURRENT_SIZE = 8 | |
| INPUT_SIZE = N_DRIVES + N_CONTEXT + RECURRENT_SIZE | |
| HIDDEN_SIZE = 24 | |
| WEIGHT_DECAY = 0.995 # L2 decay per step — prevents weight explosion / signal saturation | |
| # ══════════════════════════════════════════════ | |
| # Fallback Signal & Drive Config (used if config/prompts/signal_buckets.yaml doesn't exist) | |
| # ══════════════════════════════════════════════ | |
| _FB_SIG_CN = { | |
| 'directness': '直接感', 'vulnerability': '脆弱感', | |
| 'playfulness': '玩闹感', 'initiative': '主动性', | |
| 'depth': '深度', 'warmth': '温暖度', | |
| 'defiance': '倔强度', 'curiosity': '好奇心', | |
| } | |
| _FB_ANCHORS = { | |
| 'directness': ('委婉', '直白'), 'vulnerability': ('封闭', '袒露'), | |
| 'playfulness': ('正经', '调皮'), 'initiative': ('被动', '主导'), | |
| 'depth': ('闲聊', '探底'), 'warmth': ('疏离', '热切'), | |
| 'defiance': ('随和', '硬杠'), 'curiosity': ('无感', '追问'), | |
| } | |
| _FB_SIGNAL_CONFIG = { | |
| sig: { | |
| 'label': _FB_SIG_CN[sig], | |
| 'emoji_label': SIGNAL_LABELS[sig], | |
| 'low_anchor': _FB_ANCHORS[sig][0], | |
| 'high_anchor': _FB_ANCHORS[sig][1], | |
| } | |
| for sig in SIGNALS | |
| } | |
| _FB_DRIVE_CONFIG = { | |
| d: {'label': DRIVE_LABELS[d].split(' ')[1], 'emoji_label': DRIVE_LABELS[d]} | |
| for d in DRIVES | |
| } | |
| # ══════════════════════════════════════════════ | |
| # Conversation Scenario Templates | |
| # ══════════════════════════════════════════════ | |
| SCENARIOS = { | |
| '深夜心事': { | |
| 'user_emotion': -0.3, 'topic_intimacy': 0.9, 'time_of_day': 0.95, | |
| 'conversation_depth': 0.7, 'user_engagement': 0.8, | |
| 'conflict_level': 0.0, 'novelty_level': 0.2, 'user_vulnerability': 0.9, | |
| }, | |
| '日常闲聊': { | |
| 'user_emotion': 0.3, 'topic_intimacy': 0.2, 'time_of_day': 0.5, | |
| 'conversation_depth': 0.2, 'user_engagement': 0.5, | |
| 'conflict_level': 0.0, 'novelty_level': 0.3, 'user_vulnerability': 0.2, | |
| }, | |
| '吵架冲突': { | |
| 'user_emotion': -0.8, 'topic_intimacy': 0.6, 'time_of_day': 0.7, | |
| 'conversation_depth': 0.5, 'user_engagement': 0.9, | |
| 'conflict_level': 0.9, 'novelty_level': 0.1, 'user_vulnerability': 0.1, | |
| }, | |
| '分享喜悦': { | |
| 'user_emotion': 0.9, 'topic_intimacy': 0.5, 'time_of_day': 0.4, | |
| 'conversation_depth': 0.3, 'user_engagement': 0.9, | |
| 'conflict_level': 0.0, 'novelty_level': 0.6, 'user_vulnerability': 0.3, | |
| }, | |
| } | |
| def simulate_conversation(agent: 'Agent', scenario_sequence: list, | |
| reward_fn=None, steps_per_scenario: int = 20) -> None: | |
| """ | |
| Pre-warm Agent neural network through simulated scenario steps. | |
| This is the key bootstrap that creates cross-seed personality diversity — | |
| without it, all agents start from the same neutral state and the LLM's | |
| default prior dominates. With 60 steps (3 scenarios × 20), the random | |
| neural network has already been shaped by experience before turn 1. | |
| Args: | |
| agent: The Agent to pre-warm | |
| scenario_sequence: List of scenario names from SCENARIOS dict | |
| reward_fn: Optional custom reward function (agent, signals, ctx) → float | |
| steps_per_scenario: Steps per scenario (default 20, total 60 for 3 scenarios) | |
| """ | |
| for scenario_name in scenario_sequence: | |
| ctx = SCENARIOS[scenario_name].copy() | |
| for step in range(steps_per_scenario): | |
| ctx['conversation_depth'] = min(1.0, ctx['conversation_depth'] + 0.02) | |
| if reward_fn: | |
| signals = agent.compute_signals(ctx) | |
| reward = reward_fn(agent, signals, ctx) | |
| else: | |
| reward = random.gauss(0.2, 0.3) # Slightly positive default | |
| # Synthetic satisfaction: positive reward → uniform micro-satisfaction | |
| sat = {d: max(0.0, reward * 0.05) for d in DRIVES} if reward > 0 else None | |
| agent.step(ctx, reward, drive_satisfaction=sat) | |
| # ══════════════════════════════════════════════ | |
| # The Agent: Living Personality | |
| # ══════════════════════════════════════════════ | |
| class Agent: | |
| """ | |
| A personality entity with internal drives, random neural network, | |
| and evolvable weights via Hebbian learning. | |
| No hardcoded personality traits — all behavior emerges from | |
| the random network's computation. | |
| """ | |
| def __init__(self, seed: int, engine_params: dict = None): | |
| self.seed = seed | |
| rng = random.Random(seed) | |
| # Per-persona engine parameters | |
| params = engine_params or {} | |
| self.hebbian_lr = params.get('hebbian_lr', 0.02) | |
| self.phase_threshold = params.get('phase_threshold', 2.0) | |
| # ── Genome: drive parameters ── | |
| self.drive_baseline = {d: rng.uniform(0.2, 0.8) for d in DRIVES} | |
| self.drive_accumulation_rate = {d: rng.uniform(0.01, 0.05) for d in DRIVES} | |
| self.drive_decay_rate = {d: rng.uniform(0.05, 0.15) for d in DRIVES} | |
| # ── Current drive state ── | |
| self.drive_state = {d: self.drive_baseline[d] for d in DRIVES} | |
| # ── Genome: random neural network weights ── | |
| self.W1 = [[rng.gauss(0, 0.6) for _ in range(INPUT_SIZE)] for _ in range(HIDDEN_SIZE)] | |
| self.b1 = [rng.gauss(0, 0.3) for _ in range(HIDDEN_SIZE)] | |
| self.W2 = [[rng.gauss(0, 0.2) for _ in range(HIDDEN_SIZE)] for _ in range(N_SIGNALS)] | |
| self.b2 = [rng.gauss(0, 0.2) for _ in range(N_SIGNALS)] | |
| # ── Recurrent state (internal "mood") ── | |
| self.recurrent_state = [rng.gauss(0, 0.1) for _ in range(RECURRENT_SIZE)] | |
| # ── Tracking ── | |
| self.interaction_count = 0 | |
| self.total_reward = 0.0 | |
| self.age = 0 | |
| self._frustration = 0.0 | |
| self._last_hidden = None | |
| self._last_input = None | |
| self._last_phase_transition = False | |
| self.signal_history = [] | |
| def compute_signals(self, context: dict) -> dict: | |
| """ | |
| Core computation: context + drives + internal state → 8D behavioral signals. | |
| No personality logic — just matrix multiplication and activation functions. | |
| """ | |
| drive_vec = [self.drive_state[d] for d in DRIVES] | |
| ctx_vec = [context.get(f, 0.0) for f in CONTEXT_FEATURES] | |
| full_input = drive_vec + ctx_vec + self.recurrent_state | |
| # Perception noise (biological realism) | |
| full_input = [v + random.gauss(0, 0.03) for v in full_input] | |
| # Forward pass: hidden layer | |
| hidden = [] | |
| for i in range(HIDDEN_SIZE): | |
| z = self.b1[i] | |
| for j, x in enumerate(full_input): | |
| z += self.W1[i][j] * x | |
| hidden.append(math.tanh(z)) | |
| # Update recurrent state | |
| self.recurrent_state = hidden[:RECURRENT_SIZE] | |
| self._last_hidden = list(hidden) | |
| self._last_input = list(full_input) | |
| # Output layer: behavioral signals | |
| raw_signals = [] | |
| for i in range(N_SIGNALS): | |
| z = self.b2[i] | |
| for j, h in enumerate(hidden): | |
| z += self.W2[i][j] * h | |
| z /= math.sqrt(HIDDEN_SIZE / 3) # Scaled normalization — prevents sigmoid saturation while preserving signal spread | |
| raw_signals.append(z) | |
| # Sigmoid → [0, 1] | |
| signals = {} | |
| for i, name in enumerate(SIGNALS): | |
| signals[name] = 1.0 / (1.0 + math.exp(-max(-10, min(10, raw_signals[i])))) | |
| # Track for personality_fingerprint | |
| self.signal_history.append(dict(signals)) | |
| if len(self.signal_history) > 200: | |
| self.signal_history = self.signal_history[-100:] | |
| return signals | |
| def satisfy_drive(self, drive_name: str, amount: float): | |
| """Satisfy a drive (reduce its current level).""" | |
| if drive_name in self.drive_state: | |
| self.drive_state[drive_name] = max(0, self.drive_state[drive_name] - amount) | |
| def tick_drives(self): | |
| """Natural drive accumulation per step.""" | |
| for d in DRIVES: | |
| self.drive_state[d] = min(1.0, self.drive_state[d] + self.drive_accumulation_rate[d]) | |
| def learn(self, signals: dict, reward: float, context: dict, | |
| drive_satisfaction: dict = None): | |
| """ | |
| Hebbian learning: reinforce connections that produced good results. | |
| Includes frustration accumulation, phase transitions, and drive satisfaction. | |
| drive_satisfaction: If provided (from Critic LLM), uses LLM-judged satisfaction. | |
| If None (pre-warming/smoke test), uses rule-based fallback. | |
| """ | |
| lr = self.hebbian_lr * (1 + abs(reward)) | |
| self._last_phase_transition = False | |
| hidden = getattr(self, '_last_hidden', | |
| self.recurrent_state + [0.0] * (HIDDEN_SIZE - RECURRENT_SIZE)) | |
| full_input = getattr(self, '_last_input', None) | |
| # Update output layer weights W2 | |
| for i, sig_name in enumerate(SIGNALS): | |
| sig_val = signals[sig_name] | |
| for j in range(HIDDEN_SIZE): | |
| if abs(hidden[j]) > 0.05: | |
| self.W2[i][j] += lr * reward * hidden[j] * (sig_val - 0.5) | |
| # Update hidden layer weights W1 | |
| if abs(reward) > 0.05: | |
| for i in range(HIDDEN_SIZE): | |
| if abs(hidden[i]) > 0.15: | |
| for j in range(INPUT_SIZE): | |
| if full_input and abs(full_input[j]) > 0.05: | |
| self.W1[i][j] += lr * 0.3 * reward * full_input[j] * hidden[i] | |
| # Frustration accumulation → phase transition | |
| if reward < -0.1: | |
| self._frustration += abs(reward) | |
| else: | |
| self._frustration = max(0, self._frustration - reward * 0.5) | |
| # Phase transition when frustration exceeds threshold | |
| if self._frustration > self.phase_threshold: | |
| for i in range(N_SIGNALS): | |
| sig_val = signals[SIGNALS[i]] | |
| kick = -0.3 * (sig_val - 0.5) + random.gauss(0, 0.15) | |
| self.b2[i] += kick | |
| for i in range(HIDDEN_SIZE): | |
| self.b1[i] += random.gauss(0, 0.1) | |
| self._frustration = 0.0 | |
| self._last_phase_transition = True | |
| # Drive satisfaction (LLM-judged — caller must provide) | |
| if drive_satisfaction: | |
| for d in DRIVES: | |
| self.satisfy_drive(d, drive_satisfaction.get(d, 0.0)) | |
| self.total_reward += reward | |
| self.interaction_count += 1 | |
| # Weight decay + clamp — prevent weight explosion / signal saturation | |
| for i in range(N_SIGNALS): | |
| for j in range(HIDDEN_SIZE): | |
| self.W2[i][j] *= WEIGHT_DECAY | |
| self.W2[i][j] = max(-1.5, min(1.5, self.W2[i][j])) | |
| for i in range(HIDDEN_SIZE): | |
| for j in range(INPUT_SIZE): | |
| self.W1[i][j] *= WEIGHT_DECAY | |
| self.W1[i][j] = max(-2.0, min(2.0, self.W1[i][j])) | |
| def step(self, context: dict, reward: float = 0.0, | |
| drive_satisfaction: dict = None) -> dict: | |
| """One full cycle: sense → compute signals → learn → tick drives.""" | |
| signals = self.compute_signals(context) | |
| self.learn(signals, reward, context, drive_satisfaction=drive_satisfaction) | |
| self.tick_drives() | |
| self.age += 1 | |
| return signals | |
| def get_dominant_drive(self) -> str: | |
| """Return the most urgent drive.""" | |
| return max(self.drive_state, key=self.drive_state.get) | |
| def personality_fingerprint(self, window_size: int = 30) -> dict: | |
| """ | |
| Analyzes recent signal history to identify stable traits and contradictions. | |
| """ | |
| if not self.signal_history: | |
| return {'traits': {}, 'contradictions': []} | |
| recent_signals = self.signal_history[-window_size:] | |
| num_signals = len(recent_signals) | |
| if num_signals == 0: | |
| return {'traits': {}, 'contradictions': []} | |
| # Calculate average signal values | |
| avg_signals = {sig_name: 0.0 for sig_name in SIGNALS} | |
| for signals_t in recent_signals: | |
| for sig_name, value in signals_t.items(): | |
| avg_signals[sig_name] += value | |
| for sig_name in SIGNALS: | |
| avg_signals[sig_name] /= num_signals | |
| # Identify stable traits (signals consistently high or low) | |
| traits = {} | |
| for sig_name in SIGNALS: | |
| if avg_signals[sig_name] > 0.7: | |
| traits[sig_name] = 'high' | |
| elif avg_signals[sig_name] < 0.3: | |
| traits[sig_name] = 'low' | |
| else: | |
| traits[sig_name] = 'neutral' | |
| # Identify contradictions (signals that frequently swing from high to low) | |
| contradictions = [] | |
| for i in range(N_SIGNALS): | |
| for j in range(i + 1, N_SIGNALS): | |
| sig1_name = SIGNALS[i] | |
| sig2_name = SIGNALS[j] | |
| high_low_count = 0 | |
| low_high_count = 0 | |
| for k in range(num_signals - 1): | |
| s_t = recent_signals[k] | |
| s_t1 = recent_signals[k+1] | |
| # Check for high-to-low swing for sig1 while sig2 is low-to-high | |
| if (s_t[sig1_name] > 0.7 and s_t1[sig1_name] < 0.3 and | |
| s_t[sig2_name] < 0.3 and s_t1[sig2_name] > 0.7): | |
| high_low_count += 1 | |
| # Check for low-to-high swing for sig1 while sig2 is high-to-low | |
| elif (s_t[sig1_name] < 0.3 and s_t1[sig1_name] > 0.7 and | |
| s_t[sig2_name] > 0.7 and s_t1[sig2_name] < 0.3): | |
| low_high_count += 1 | |
| # If both swings happen frequently, it's a contradiction | |
| if high_low_count > num_signals * 0.1 and low_high_count > num_signals * 0.1: | |
| contradictions.append((sig1_name, sig2_name)) | |
| return { | |
| 'traits': traits, | |
| 'avg_signals': avg_signals, | |
| 'contradictions': contradictions, | |
| } | |
| def to_prompt_injection(self, context: dict) -> str: | |
| """Legacy compat: compute signals from context, then format. | |
| Prefer to_prompt_injection_from_signals() when signals are pre-computed. | |
| """ | |
| signals = self.compute_signals(context) | |
| return self.to_prompt_injection_from_signals(signals) | |
| def to_prompt_injection_from_signals( | |
| self, signals: dict, | |
| signal_overrides: dict = None, | |
| frustration: dict = None, | |
| lang: str = 'zh', | |
| ) -> str: | |
| """ | |
| Convert pre-computed behavioral signals into text for LLM system prompt. | |
| v12: De-descriptified — numbers + scale endpoints only, no bucket descriptions. | |
| The LLM interprets signal values through persona + conversation context, | |
| producing emergent behavior instead of executing static descriptions. | |
| Args: | |
| signals: 8D behavioral signals (0~1). | |
| signal_overrides: Per-persona overrides for emoji_label / anchors. | |
| frustration: Per-drive frustration dict from DriveMetabolism (0~5). | |
| lang: Label language ('zh' or 'en'). | |
| """ | |
| from engine.prompt_registry import load_signal_config | |
| is_en = lang == 'en' | |
| # ── Load from YAML (or use module-level fallbacks) ── | |
| config = load_signal_config( | |
| fallback_signals=_FB_SIGNAL_CONFIG, | |
| fallback_drives=_FB_DRIVE_CONFIG, | |
| ) | |
| sig_config = config.get('signals', _FB_SIGNAL_CONFIG) | |
| drv_config = config.get('drives', _FB_DRIVE_CONFIG) | |
| # Per-persona overrides (emoji_label, anchors) | |
| if signal_overrides: | |
| import copy | |
| sig_config = copy.deepcopy(sig_config) | |
| for sig_name, override in signal_overrides.items(): | |
| if sig_name in sig_config: | |
| for key in ('emoji_label', 'low_anchor', 'high_anchor'): | |
| if key in override: | |
| sig_config[sig_name][key] = override[key] | |
| # ── Signal state: number + scale endpoints ── | |
| header = "[Stage direction: character current state]" if is_en else "【舞台指令:角色当前状态】" | |
| lines = [header] | |
| for sig_name in SIGNALS: | |
| val = signals[sig_name] | |
| info = sig_config.get(sig_name, {}) | |
| emoji_label = info.get('emoji_label_en', sig_name) if is_en else info.get('emoji_label', sig_name) | |
| lo = info.get('low_anchor_en', info.get('low_anchor', 'low')) if is_en else info.get('low_anchor', '低') | |
| hi = info.get('high_anchor_en', info.get('high_anchor', 'high')) if is_en else info.get('high_anchor', '高') | |
| lines.append(f"{emoji_label}: {val:.2f} (0{lo}→1{hi})") | |
| # ── All 5 drives + per-drive frustration ── | |
| lines.append("") | |
| drive_header = "[Stage direction: character inner needs]" if is_en else "【舞台指令:角色内在需求】" | |
| lines.append(drive_header) | |
| frust = frustration or {} | |
| craving_label = "craving" if is_en else "渴望" | |
| baseline_label = "baseline" if is_en else "基线" | |
| for d in DRIVES: | |
| d_info = drv_config.get(d, {}) | |
| d_label = d_info.get('emoji_label_en', d) if is_en else d_info.get('emoji_label', d) | |
| d_val = self.drive_state[d] | |
| d_base = self.drive_baseline[d] | |
| d_frust = frust.get(d, 0.0) | |
| lines.append(f"{d_label}: {d_val:.2f} ({baseline_label}: {d_base:.2f}, {craving_label}: {d_frust:.1f})") | |
| return '\n'.join(lines) | |
| # ── Serialization ── | |
| def to_dict(self) -> dict: | |
| """Serialize agent state for persistence.""" | |
| return { | |
| 'seed': self.seed, | |
| 'drive_state': dict(self.drive_state), | |
| 'drive_baseline': dict(self.drive_baseline), | |
| 'W1': self.W1, | |
| 'b1': self.b1, | |
| 'W2': self.W2, | |
| 'b2': self.b2, | |
| 'recurrent_state': self.recurrent_state, | |
| 'interaction_count': self.interaction_count, | |
| 'total_reward': self.total_reward, | |
| 'age': self.age, | |
| '_frustration': self._frustration, | |
| 'signal_history': self.signal_history[-100:], # Persist last 100 for personality_fingerprint | |
| } | |
| def from_dict(cls, data: dict) -> Agent: | |
| """Restore agent from serialized state. | |
| Handles backward compatibility: old agents have 21D input (8D context) | |
| while new agents have 25D input (12D context with EverMemOS dims). | |
| """ | |
| agent = cls(seed=data['seed']) | |
| agent.drive_state = data.get('drive_state', agent.drive_state) | |
| agent.drive_baseline = data.get('drive_baseline', agent.drive_baseline) # P1: restore evolved baseline | |
| saved_W1 = data.get('W1', agent.W1) | |
| # Backward compat: expand 21D → 25D if loading old weights | |
| if saved_W1 and len(saved_W1[0]) < INPUT_SIZE: | |
| rng = random.Random(data['seed'] + 9999) # deterministic expansion | |
| extra_cols = INPUT_SIZE - len(saved_W1[0]) | |
| for row in saved_W1: | |
| row.extend([rng.gauss(0, 0.3) for _ in range(extra_cols)]) | |
| agent.W1 = saved_W1 | |
| agent.b1 = data.get('b1', agent.b1) | |
| agent.W2 = data.get('W2', agent.W2) | |
| agent.b2 = data.get('b2', agent.b2) | |
| agent.recurrent_state = data.get('recurrent_state', agent.recurrent_state) | |
| agent.interaction_count = data.get('interaction_count', 0) | |
| agent.total_reward = data.get('total_reward', 0.0) | |
| agent.signal_history = data.get('signal_history', []) | |
| agent.age = data.get('age', 0) | |
| agent._frustration = data.get('_frustration', 0.0) | |
| return agent | |