""" ChatAgent — Genome v10 Hybrid lifecycle-powered conversational agent. Per-turn lifecycle (with EverMemOS async memory): 0. EverMemOS session context (first turn only: async load profile) 1. Time metabolism (DriveMetabolism) 2. Critic perception (LLM → 8D context + frustration delta + relationship delta) 2.5 Semi-emergent relationship update: posterior = clip(prior + LLM_delta) alpha = clip(0.15 + 0.5*depth, 0.15, 0.65) ema_state = alpha*posterior + (1-alpha)*prev 3. LLM metabolism (apply frustration delta → reward) 3.5 Critic-driven Drive baseline evolution (BASELINE_LR=0.01, every turn) frustration_delta > 0 → drive not satisfied → baseline rises frustration_delta < 0 → drive satisfied → baseline eases No math formula. Purely LLM-judged, same structure as Hebbian learning. 4. Crystallization gate (composite score: reward + novelty×engagement + conflict penalty) 5. Compute signals (Agent neural network, 12D context) 6. Thermodynamic noise injection 7. KNN retrieval (ContinuousStyleMemory) 8. Build Actor prompt (persona + signals + few-shot) 8.5 Profile/Episode memory injection (user facts + narrative) 9. LLM Actor (generate response with monologue + reply) 10. Hebbian learning (Agent.step) 11. EverMemOS store_turn → asyncio.create_task (non-blocking) """ from __future__ import annotations import asyncio import hashlib import re import time from typing import AsyncIterator, Optional from providers.llm.client import LLMClient, ChatMessage, ChatResponse from persona.loader import Persona from engine.genome.genome_engine import Agent, DRIVES, SIGNALS, DRIVE_LABELS from engine.genome.drive_metabolism import DriveMetabolism, apply_thermodynamic_noise from engine.genome.critic import critic_sense from engine.genome.style_memory import ContinuousStyleMemory from memory.memory_store import MemoryStore # Parser utilities (extracted to agent/parser.py) from agent.parser import extract_reply, _parse_modality, _SECTION_RE, _TAG_MAP # Mixin modules (extracted from this file) from agent.prompt_builder import PromptBuilderMixin from agent.evermemos_mixin import EverMemosMixin from agent.modality_retry import ModalityRetryMixin from agent.proactive import ProactiveMixin class ChatAgent(PromptBuilderMixin, EverMemosMixin, ModalityRetryMixin, ProactiveMixin): """ Genome v8 lifecycle-powered persona chat agent. Each instance represents one user ↔ persona conversation session, backed by a living personality engine (Agent + DriveMetabolism + ContinuousStyleMemory). """ def __init__( self, persona: Persona, llm: LLMClient, user_id: str = "default_user", user_name: Optional[str] = None, task_skill_engine=None, modality_skill_engine=None, skills_prompt: Optional[str] = None, skill_engine=None, memory_store: Optional[MemoryStore] = None, genome_seed: int = 42, genome_data_dir: Optional[str] = None, max_history: int = 40, evermemos=None, task_log_store=None, ): self.persona = persona self.llm = llm self.user_id = user_id self.user_name = user_name # Dual skill engines (isolated) self.task_skill_engine = task_skill_engine or skill_engine # backward compat self.modality_skill_engine = modality_skill_engine self.skills_prompt = skills_prompt or "" self.task_log_store = task_log_store self.memory_store = memory_store self.max_history = max_history # ── Genome v8 Engine (with per-persona params) ── engine_params = persona.engine_params self.agent = Agent(seed=genome_seed, engine_params=engine_params) self.metabolism = DriveMetabolism(engine_params=engine_params) # Per-persona tunable parameters (with defaults) self.baseline_lr = engine_params.get('baseline_lr', 0.01) self.elasticity = engine_params.get('elasticity', 0.05) self.crystal_threshold = engine_params.get('crystal_threshold', 0.50) self.trend_delta = engine_params.get('trend_delta', 0.15) # Apply persona-specific genome seed (initial conditions only) if persona.drive_baseline: for d, v in persona.drive_baseline.items(): if d in self.agent.drive_baseline: self.agent.drive_baseline[d] = float(v) self.agent.drive_state[d] = float(v) # Snapshot initial baseline for elastic pullback (persona gravity) self._initial_baseline = dict(self.agent.drive_baseline) self.style_memory = ContinuousStyleMemory( agent_id=f"{persona.persona_id}_{user_id}", db_dir=genome_data_dir, persona_id=persona.persona_id, hawking_gamma=engine_params.get('hawking_gamma'), ) # ── Conversation state ── self.history: list[ChatMessage] = [] self._turn_count: int = 0 self._last_action: Optional[dict] = None self._last_critic: Optional[dict] = None self._last_signals: Optional[dict] = None self._prev_signals: Optional[dict] = None # Previous turn signals for trend injection self._last_reward: float = 0.0 self._last_modality: str = "" self._skill_outputs: dict = {} # all modality skill results, reset per turn self._last_drive_satisfaction: dict = {} # ── Concurrency lock (R2: serialize chat/stream/proactive_tick) ── self._turn_lock = asyncio.Lock() # ── Proactive tick state ── self._last_active: float = time.time() self._state_version: int = 0 self._interaction_cadence: float = 0.0 # EMA of interaction interval (seconds) # ── EverMemOS Async Memory ── self.evermemos = evermemos self.evermemos_uid = f"{user_id}__{persona.persona_id}" # sender for user messages in store self._group_id = f"{persona.persona_id}__{user_id}" # group_id scopes per user-persona pair self._user_profile: str = "" self._episode_summary: str = "" # Narrative history for Critic + Actor self._session_ctx = None # SessionContext loaded on first turn # ── Phase 1 Emergence: Relationship EMA state ── self._relationship_ema: dict = {} # Populated on first turn from prior # ── Phase 3: Query-based relevance retrieval ── self._relevant_facts: str = "" # Populated by async search from previous turn self._relevant_episodes: str = "" # Populated by async search from previous turn self._relevant_profile: str = "" # P1: Profile attrs from search self._foresight_text: str = "" # P1: Foresight content from session context self._search_task: Optional[asyncio.Task] = None # Tracks background search self._search_turn_id: int = 0 # Turn that fired the search (concurrency guard) self._search_hit: int = 0 # Observability: successful search collections self._search_timeout: int = 0 # Observability: timeout fallbacks self._search_fallback: int = 0 # Observability: turns that used static (per-turn) self._search_relevant_used: int = 0 # Observability: turns that injected relevant self._turn_used_fallback: bool = False # Per-turn flag, reset each turn evermemos_status = "ON" if (evermemos and evermemos.available) else "OFF" m = self.metabolism print(f"✓ ChatAgent(Genome v10+EverMemOS) 初始化: {persona.name} ↔ {user_name or user_id} " f"(seed={genome_seed}, memories={self.style_memory.total_memories}, evermemos={evermemos_status})") print(f" [metabolism] conn_k={m.connection_hunger_k}, nov_k={m.novelty_hunger_k}, " f"decay={m.decay_lambda}, temp_coeff={m.temp_coeff}, temp_floor={m.temp_floor}") def pre_warm(self, scenarios: list | None = None, steps_per_scenario: int = 20) -> None: """ Pre-warm the Agent's neural network via simulated scenario steps. Call this ONCE on brand-new agents (before any real conversation). Restored agents already have shaped weights — calling this again would corrupt their evolved personality; always guard with age check: if agent.agent.age == 0: agent.pre_warm() Args: scenarios: Scenario sequence; defaults to V10 standard 3-phase. steps_per_scenario: Steps per scenario (default 20 → 60 total). """ from engine.genome.genome_engine import simulate_conversation, DRIVES if scenarios is None: scenarios = ['分享喜悦', '吵架冲突', '深夜心事'] simulate_conversation(self.agent, scenarios, steps_per_scenario=steps_per_scenario) # Reset drive_state to baseline after pre_warm. # Pre_warm shaped the NN weights (W1/W2) — that's its real job. # The saturated drive_state (all → ~1.0) is a side effect of 60 steps # of positive-biased rewards and must not leak into real conversation. for d in DRIVES: self.agent.drive_state[d] = self.agent.drive_baseline[d] self.agent._frustration = 0.0 # ── Prompt building, memory blending, crystallization ── # See agent/prompt_builder.py (PromptBuilderMixin) async def chat(self, user_message: str, on_feel_done=None, is_proactive: bool = False) -> dict: """ Process a user message through the full Genome v10 lifecycle. Returns only the reply (monologue is stored internally). on_feel_done: optional async callback invoked when prompt is ready (before LLM call). is_proactive: if True, this is a self-driven message — skip user memory/history storage. """ async with self._turn_lock: return await self._chat_inner(user_message, on_feel_done=on_feel_done, is_proactive=is_proactive) async def _run_task_skills(self, user_message: str) -> str: """Step -1: Run task skill ReAct loop before persona engine. Returns user_message (unchanged or enriched with observations). """ if not self.task_skill_engine: return user_message try: observations = await self.task_skill_engine.react_loop(user_message, self.llm) if observations: user_message = ( f"{user_message}\n\n" f"[以下是真实查询数据,回复中必须自然融入关键数值,不要省略]\n" f"{observations}" ) print(f" [skill] ✅ 数据已注入 ({len(observations)} chars), 继续引擎处理") except Exception as e: print(f" [skill] ⚠ ReAct loop failed ({e}), fallback to persona engine") return user_message async def _chat_inner(self, user_message: str, on_feel_done=None, is_proactive: bool = False) -> dict: """Inner chat implementation (called under lock).""" # ── Step -1: Task skill ReAct loop (before persona engine) ── user_message = await self._run_task_skills(user_message) # ── Step 0: persona engine (zero changes below this line) ── self._turn_count += 1 self._turn_used_fallback = False # Reset per-turn fallback flag now = time.time() # Update interaction cadence (EMA) if self._last_active > 0: delta = now - self._last_active if self._interaction_cadence > 0: self._interaction_cadence = 0.3 * delta + 0.7 * self._interaction_cadence else: self._interaction_cadence = delta self._last_active = now # ── Step 0: EverMemOS session context (first turn only) ── relationship_prior = await self._evermemos_gather() # ── Step 1: Time metabolism ── delta_h = self.metabolism.time_metabolism(now) # ── Step 2: Critic perception (8D context + 5D delta + 3D relationship) ── frust_dict = {d: round(self.metabolism.frustration[d], 2) for d in DRIVES} # Build persona hint for persona-aware Critic _p = self.persona _mbti = getattr(_p, 'mbti', '') or '未知' _tags = '、'.join(getattr(_p, 'tags', [])[:3]) _persona_hint = f"{_p.name} ({_mbti}) — {_tags}" if _tags else f"{_p.name} ({_mbti})" context, frustration_delta, rel_delta, drive_satisfaction = await critic_sense( user_message, self.llm, frust_dict, user_profile=self._user_profile, episode_summary=self._episode_summary, persona_hint=_persona_hint, ) # ── Step 2.5: Semi-emergent relationship update (prior + delta + clip + EMA) ── relationship_4d = self._apply_relationship_ema( relationship_prior, rel_delta, context.get('conversation_depth', 0.0) ) context.update(relationship_4d) # Merge 8D + 4D → 12D self._last_critic = context # Store full 12D context (after merge) # ── Step 3: LLM metabolism → reward ── reward = self.metabolism.apply_llm_delta(frustration_delta) self.metabolism.sync_to_agent(self.agent) self._last_reward = reward # ── Step 3.5: Critic-driven Drive baseline evolution ── # Elastic baseline: spring force pulls baseline back toward persona origin. # Prevents unbounded drift while preserving local emergence. # frustration_delta > 0 = drive not satisfied this turn → baseline rises (hungers more) # frustration_delta < 0 = drive satisfied this turn → baseline eases for d in DRIVES: shift = frustration_delta.get(d, 0.0) * self.baseline_lr drift = self.agent.drive_baseline[d] - self._initial_baseline.get(d, 0.5) pull_back = -drift * self.elasticity self.agent.drive_baseline[d] = max(0.1, min(0.95, self.agent.drive_baseline[d] + shift + pull_back )) # ── Step 4: Crystallization gate (last action) ── if self._last_action and self._should_crystallize(reward, context): self.style_memory.set_clock(now) self.style_memory.crystallize( self._last_action['context'], self._last_action['monologue'], self._last_action['reply'], self._last_action['user_input'], ) # ── Step 5: Compute signals (context from Critic directly) ── base_signals = self.agent.compute_signals(context) # ── Step 6: Thermodynamic noise ── total_frust = self.metabolism.total() noisy_signals = self.metabolism.apply_thermodynamic_noise(base_signals) self._prev_signals = self._last_signals # Track for trend injection self._last_signals = noisy_signals # ── Step 7: KNN retrieval (full examples for single-pass) ── self.style_memory.set_clock(now) few_shot = self.style_memory.build_few_shot_prompt( context, top_k=3, monologue_only=False, lang=self.persona.lang, ) # ── Step 8: Build single-pass prompt (actor_single template) ── single_prompt = self._build_single_prompt( few_shot, noisy_signals, modality_skill_engine=self.modality_skill_engine, ) # ── Step 8.5: Memory injection into prompt ── if self._session_ctx and self._session_ctx.has_history: await self._collect_search_results() profile_budget, episode_budget = self._memory_injection_budget(context) profile_text = self._blend_injection( self._relevant_facts, self._user_profile, profile_budget ) episode_text = self._blend_injection( self._relevant_episodes, self._episode_summary, episode_budget ) name = self.user_name or "你" if self.persona.lang == 'en': if profile_text: single_prompt += f"\n\n[{name}'s preferences] {profile_text}" if episode_text: single_prompt += f"\n\n[Past interactions with {name}] {episode_text}" if self._foresight_text: single_prompt += f"\n\n[Worth noting] {self._foresight_text}" if self._relevant_profile: single_prompt += f"\n\n[{name}'s profile] {self._relevant_profile}" else: if profile_text: single_prompt += f"\n\n[关于{name}的偏好] {profile_text}" if episode_text: single_prompt += f"\n\n[与{name}过去发生的事] {episode_text}" if self._foresight_text: single_prompt += f"\n\n[近期值得关心] {self._foresight_text}" if self._relevant_profile: single_prompt += f"\n\n[{name}的画像] {self._relevant_profile}" if self._relevant_facts or self._relevant_episodes or self._relevant_profile: self._search_relevant_used += 1 # ── Step 9: Single-pass LLM call ── single_messages = [ChatMessage(role="system", content=single_prompt)] single_messages.extend(self.history[-self.max_history:]) # Full history single_messages.append(ChatMessage(role="user", content=user_message)) # Notify caller that prompt is built (typing indicator can start) if on_feel_done: await on_feel_done() single_response = await self.llm.chat(single_messages) monologue, reply, modality = extract_reply(single_response.content) # ── Step 9b: Modality skill execution ── self._skill_outputs = {} # reset each turn self._pending_retry = None # reset each turn skill_result = None # default when no skill runs # Extract raw modality text (full LLM output after 【表达方式】) _raw_mod = "" _matches = list(_SECTION_RE.finditer(single_response.content)) if _matches: _raw_mod = single_response.content[_matches[-1].end():].strip() print(f" [express] raw_modality='{_raw_mod[:80]}'") # Scan raw_modality for registered SKILL keywords — LLM plans execution if self.modality_skill_engine and _raw_mod: skill_results = await self.modality_skill_engine.plan_and_execute( raw_modality=_raw_mod, raw_output=single_response.content, persona=self.persona, llm=self.llm, chat_history=self.history, ) for skill_result in skill_results: if not skill_result.success: continue self._skill_outputs.update(skill_result.output) # Modality from LLM plan, not parser if self._skill_outputs.get("_modality"): modality = self._skill_outputs["_modality"] if not skill_results: # No skills matched — plain text pass elif all(not r.success for r in skill_results): # All skills failed — fallback print(f" [skill] ⚠ All skills failed, triggering LLM fallback") fallback_reply = await self._modality_failure_with_retry( modality, reply, single_response.content ) if fallback_reply: reply = fallback_reply modality = "文字" self._fallback_history_added = True # ── Step 10: Hebbian learning ── clamped_reward = max(-1.0, min(1.0, reward)) self.agent.step(context, reward=clamped_reward, drive_satisfaction=drive_satisfaction) self._last_drive_satisfaction = drive_satisfaction # ── Update state ── if not is_proactive: self.history.append(ChatMessage(role="user", content=user_message)) if not getattr(self, '_fallback_history_added', False): self.history.append(ChatMessage(role="assistant", content=reply)) self._fallback_history_added = False if len(self.history) > self.max_history: self.history = self.history[-self.max_history:] self._last_action = { 'context': context, 'monologue': monologue, 'reply': reply, 'modality': modality, 'user_input': user_message, } self._last_modality = modality # Store facts in keyword memory (skip for proactive — not real user input) if self.memory_store and not is_proactive: self.memory_store.add( user_id=self.user_id, persona_id=self.persona.persona_id, content=user_message, category="user_message", importance=context.get('entropy', 0.5), ) sat_str = ' '.join(f'{d[:3]}={v:.2f}' for d, v in drive_satisfaction.items() if v > 0) print(f" [genome] reward={reward:.2f} temp={self.metabolism.temperature():.3f} modality={modality[:30]}") print(f" [feel] monologue={monologue[:60]}") print(f" [drive_sat] {sat_str or 'none'}") # ── Step 11: EverMemOS store_turn (non-blocking background task) ── if not is_proactive: self._evermemos_store_bg(user_message, reply) # ── Step 12: Fire async search for NEXT turn's injection ── if not is_proactive: self._evermemos_search_bg(user_message) result = {'reply': reply, 'modality': modality} if skill_result and skill_result.success: for key in ('image_path', 'audio_path', 'segments', 'delays_ms'): if skill_result.output.get(key): result[key] = skill_result.output[key] return result # _express_wrap removed — SKILL results now injected into user_message # and processed through the full persona engine (Single-Pass Actor). def _log_task(self, skill_id: str, user_input: str, output: dict, reply: str) -> None: """Log task execution to task.db (isolated from persona memory).""" if not self.task_log_store: return try: self.task_log_store.log_execution( persona_id=self.persona.persona_id, skill_id=skill_id, user_input=user_input, command=output.get("command", ""), stdout=output.get("stdout", ""), stderr=output.get("stderr", ""), success=output.get("success", False), reply=reply, ) except Exception as e: print(f" [task_log] save error: {e}") async def chat_stream(self, user_message: str) -> AsyncIterator[str]: """ Stream a response through the Genome v10 lifecycle. Steps 1-8 run first (Critic, metabolism, KNN), then Actor streams. """ await self._turn_lock.acquire() try: # ── Step -1: Task skill ReAct loop (before persona engine) ── user_message = await self._run_task_skills(user_message) # ── Step 0: persona engine (zero changes below this line) ── self._turn_count += 1 self._turn_used_fallback = False now = time.time() # Update interaction cadence (EMA) if self._last_active > 0: delta = now - self._last_active if self._interaction_cadence > 0: self._interaction_cadence = 0.3 * delta + 0.7 * self._interaction_cadence else: self._interaction_cadence = delta self._last_active = now # ── Step 0: EverMemOS session context (first turn only) ── relationship_prior = await self._evermemos_gather() # ── Step 1: Metabolism ── delta_h = self.metabolism.time_metabolism(now) # ── Step 2: Critic perception (8D context + 5D delta + 3D relationship) ── frust_dict = {d: round(self.metabolism.frustration[d], 2) for d in DRIVES} _p = self.persona _mbti = getattr(_p, 'mbti', '') or '未知' _tags = '、'.join(getattr(_p, 'tags', [])[:3]) _persona_hint = f"{_p.name} ({_mbti}) — {_tags}" if _tags else f"{_p.name} ({_mbti})" context, frustration_delta, rel_delta, drive_satisfaction = await critic_sense( user_message, self.llm, frust_dict, user_profile=self._user_profile, episode_summary=self._episode_summary, persona_hint=_persona_hint, ) # ── Step 2.5: Semi-emergent relationship update ── relationship_4d = self._apply_relationship_ema( relationship_prior, rel_delta, context.get('conversation_depth', 0.0) ) context.update(relationship_4d) self._last_critic = context reward = self.metabolism.apply_llm_delta(frustration_delta) self.metabolism.sync_to_agent(self.agent) self._last_reward = reward # ── Step 3.5: Critic-driven Drive baseline evolution ── # Elastic baseline: spring force pulls baseline back toward persona origin. # Prevents unbounded drift while preserving local emergence. for d in DRIVES: shift = frustration_delta.get(d, 0.0) * self.baseline_lr drift = self.agent.drive_baseline[d] - self._initial_baseline.get(d, 0.5) pull_back = -drift * self.elasticity self.agent.drive_baseline[d] = max(0.1, min(0.95, self.agent.drive_baseline[d] + shift + pull_back )) # ── Step 4: Crystallization ── if self._last_action and self._should_crystallize(reward, context): self.style_memory.set_clock(now) self.style_memory.crystallize( self._last_action['context'], self._last_action['monologue'], self._last_action['reply'], self._last_action['user_input'], ) # ── Steps 5-6: Signals + noise ── base_signals = self.agent.compute_signals(context) total_frust = self.metabolism.total() noisy_signals = self.metabolism.apply_thermodynamic_noise(base_signals) self._prev_signals = self._last_signals # Track for trend injection self._last_signals = noisy_signals # ── Step 7: KNN retrieval (full examples for single-pass) ── self.style_memory.set_clock(now) few_shot = self.style_memory.build_few_shot_prompt( context, top_k=3, monologue_only=False, lang=self.persona.lang, ) # ── Step 8: Build single-pass prompt (actor_single template) ── single_prompt = self._build_single_prompt( few_shot, noisy_signals, modality_skill_engine=self.modality_skill_engine, ) # ── Step 8.5: Memory injection into single-pass prompt ── if self._session_ctx and self._session_ctx.has_history: await self._collect_search_results() profile_budget, episode_budget = self._memory_injection_budget(context) profile_text = self._blend_injection( self._relevant_facts, self._user_profile, profile_budget ) episode_text = self._blend_injection( self._relevant_episodes, self._episode_summary, episode_budget ) name = self.user_name or "你" if self.persona.lang == 'en': if profile_text: single_prompt += f"\n\n[{name}'s preferences] {profile_text}" if episode_text: single_prompt += f"\n\n[Past interactions with {name}] {episode_text}" if self._foresight_text: single_prompt += f"\n\n[Worth noting] {self._foresight_text}" if self._relevant_profile: single_prompt += f"\n\n[{name}'s profile] {self._relevant_profile}" else: if profile_text: single_prompt += f"\n\n[关于{name}的偏好] {profile_text}" if episode_text: single_prompt += f"\n\n[与{name}过去发生的事] {episode_text}" if self._foresight_text: single_prompt += f"\n\n[近期值得关心] {self._foresight_text}" if self._relevant_profile: single_prompt += f"\n\n[{name}的画像] {self._relevant_profile}" if self._relevant_facts or self._relevant_episodes or self._relevant_profile: self._search_relevant_used += 1 # ── Step 9: Single-pass LLM call (streamed) ── single_messages = [ChatMessage(role="system", content=single_prompt)] single_messages.extend(self.history[-self.max_history:]) single_messages.append(ChatMessage(role="user", content=user_message)) # Signal to stream consumer that prompt is ready → "typing" can start yield "__FEEL_DONE__" full_response = [] async for chunk in self.llm.chat_stream(single_messages): full_response.append(chunk) yield chunk # ── Post-stream processing ── raw_text = "".join(full_response) monologue, reply, modality = extract_reply(raw_text) # ── Modality — let LLM output be the authority ── self._skill_outputs = {} # reset each turn self._pending_retry = None # reset each turn skill_result = None _raw_mod = "" _raw_modality_match = list(_SECTION_RE.finditer(raw_text)) if _raw_modality_match: _raw_mod = raw_text[_raw_modality_match[-1].end():].strip() print(f" [express] raw_modality='{_raw_mod[:80]}'") # Scan raw_modality for registered SKILL keywords — LLM plans execution if self.modality_skill_engine and _raw_mod: # Build structured JSON context for SKILL — clean boundary, # prevents SKILL LLM from seeing leaked content in raw Express text import json as _json structured_context = _json.dumps({ "reply": reply, "modality": modality, }, ensure_ascii=False) print(f" [skill-context] 📦 {structured_context[:200]}") skill_results = await self.modality_skill_engine.plan_and_execute( raw_modality=_raw_mod, raw_output=structured_context, persona=self.persona, llm=self.llm, chat_history=self.history, ) for skill_result in skill_results: if not skill_result.success: continue self._skill_outputs.update(skill_result.output) # Modality from LLM plan, not parser if self._skill_outputs.get("_modality"): modality = self._skill_outputs["_modality"] if skill_results and all(not r.success for r in skill_results): print(f" [skill] ⚠ All skills failed, triggering LLM fallback") fallback_reply = await self._modality_failure_with_retry( modality, reply, raw_text ) if fallback_reply: reply = fallback_reply modality = "文字" self._fallback_history_added = True # Step 10: Hebbian learning clamped_reward = max(-1.0, min(1.0, reward)) self.agent.step(context, reward=clamped_reward, drive_satisfaction=drive_satisfaction) self._last_drive_satisfaction = drive_satisfaction # Update history self.history.append(ChatMessage(role="user", content=user_message)) if not getattr(self, '_fallback_history_added', False): self.history.append(ChatMessage(role="assistant", content=reply)) self._fallback_history_added = False if len(self.history) > self.max_history: self.history = self.history[-self.max_history:] self._last_action = { 'context': context, 'monologue': monologue, 'reply': reply, 'modality': modality, 'user_input': user_message, } self._last_modality = modality sat_str = ' '.join(f'{d[:3]}={v:.2f}' for d, v in drive_satisfaction.items() if v > 0) print(f" [genome] reward={reward:.2f} temp={self.metabolism.temperature():.3f} modality={modality[:30]}") print(f" [feel] monologue={monologue[:60]}") print(f" [drive_sat] {sat_str or 'none'}") # ── Step 11: EverMemOS store_turn ── self._evermemos_store_bg(user_message, reply) # ── Step 12: Fire async search for NEXT turn ── self._evermemos_search_bg(user_message) finally: self._turn_lock.release() # ── EverMemOS integration ── # See agent/evermemos_mixin.py (EverMemosMixin) # ── Modality failure with retry ── # See agent/modality_retry.py (ModalityRetryMixin) def get_status(self) -> dict: """Get comprehensive agent status including genome state.""" mem_stats = self.style_memory.stats() metabolism_status = self.metabolism.status_summary() # Get top 3 signals for display signals_summary = {} if self._last_signals: sorted_sigs = sorted( self._last_signals.items(), key=lambda x: abs(x[1] - 0.5), reverse=True, )[:3] signals_summary = {k: round(v, 2) for k, v in sorted_sigs} dominant_drive = self.agent.get_dominant_drive() # Phase 3 metrics (all per-turn denominators) total_searches = self._search_hit + self._search_timeout search_hit_rate = self._search_hit / total_searches if total_searches else 0.0 search_timeout_rate = self._search_timeout / total_searches if total_searches else 0.0 turns = max(self._turn_count, 1) fallback_rate = self._search_fallback / turns relevant_injection_ratio = self._search_relevant_used / turns return { "persona": self.persona.name, "dominant_drive": DRIVE_LABELS.get(dominant_drive, dominant_drive), "drive_baseline": {d: round(self.agent.drive_baseline[d], 3) for d in DRIVES}, "drive_state": {d: round(self.agent.drive_state[d], 3) for d in DRIVES}, "drive_satisfaction": {d: round(v, 3) for d, v in self._last_drive_satisfaction.items()} if self._last_drive_satisfaction else {}, "signals": signals_summary, "temperature": metabolism_status['temperature'], "frustration": metabolism_status['total'], "history_length": len(self.history), "turn_count": self._turn_count, "memory_count": mem_stats.get('total', 0), "personal_memories": mem_stats.get('personal_count', 0), "age": self.agent.age, "last_reward": round(self._last_reward, 2), "modality": self._last_modality, # Relationship EMAs (Phase 1 Emergence) "relationship": { "depth": round(self._relationship_ema.get('relationship_depth', 0.0), 3), "trust": round(self._relationship_ema.get('trust_level', 0.0), 3), "valence": round(self._relationship_ema.get('emotional_valence', 0.0), 3), }, "evermemos": "ON" if (self.evermemos and self.evermemos.available) else "OFF", "search_hit": self._search_hit, "search_timeout": self._search_timeout, "search_fallback": self._search_fallback, "search_hit_rate": round(search_hit_rate, 3), "search_timeout_rate": round(search_timeout_rate, 3), "fallback_rate": round(fallback_rate, 3), "relevant_injection_ratio": round(relevant_injection_ratio, 3), **self._skill_outputs, # all skill outputs auto-forwarded } def get_debug_status(self) -> dict: """Get full engine state for developer visualization (Plan B: activations only). Returns comprehensive debug data for the neural network visualization panel. Only called when client sends debug: true. """ # 25D input vector input_vec = self.agent._last_input or [0.0] * 25 # 24D hidden layer activations hidden_vec = self.agent._last_hidden or [0.0] * 24 # 8D behavioral signals (after noise) sig = {} if self._last_signals: sig = {s: round(v, 4) for s, v in self._last_signals.items()} # 12D context vector (from Critic, after relationship merge) ctx = {} if self._last_critic: ctx = {k: round(v, 4) if isinstance(v, float) else v for k, v in self._last_critic.items()} drive_st = {d: round(self.agent.drive_state[d], 4) for d in DRIVES} sig_str = ' '.join(f'{k[:3]}={v:.2f}' for k, v in sig.items()) drv_str = ' '.join(f'{k[:3]}={v:.2f}' for k, v in drive_st.items()) mono_preview = (self._last_action.get("monologue", "") if self._last_action else "")[:40] print(f" [debug-viz] signals: {sig_str}") print(f" [debug-viz] drives: {drv_str}") print(f" [debug-viz] mono: {mono_preview}") return { "context_vector": ctx, "signals": sig, "hidden_activations": [round(h, 4) for h in hidden_vec], "input_vector": [round(v, 4) for v in input_vec], "drive_state": drive_st, "drive_baseline": {d: round(self.agent.drive_baseline[d], 4) for d in DRIVES}, "frustration": {d: round(self.metabolism.frustration[d], 4) for d in DRIVES}, "total_frustration": round(self.metabolism.total(), 4), "temperature": round(self.metabolism.temperature(), 4), "monologue": self._last_action.get("monologue", "") if self._last_action else "", "style_recall": self.style_memory.last_recall_info(), "relationship": { "depth": round(self._relationship_ema.get("relationship_depth", 0.0), 4), "trust": round(self._relationship_ema.get("trust_level", 0.0), 4), "valence": round(self._relationship_ema.get("emotional_valence", 0.0), 4), }, "reward": round(self._last_reward, 4), "age": self.agent.age, "turn_count": self._turn_count, "phase_transition": getattr(self.agent, '_last_phase_transition', False), } # ── Proactive Tick ── # See agent/proactive.py (ProactiveMixin)