diff --git a/.gitignore b/.gitignore index 0404b0703..5664b079e 100644 --- a/.gitignore +++ b/.gitignore @@ -216,4 +216,7 @@ root/ Cerebrum/* # ignore Kiro -/.kiro \ No newline at end of file +/.kiro + +# mem0 +/.mem0 \ No newline at end of file diff --git a/aios/config/config.yaml.example b/aios/config/config.yaml.example index 5dd388de0..743bb532a 100644 --- a/aios/config/config.yaml.example +++ b/aios/config/config.yaml.example @@ -65,6 +65,15 @@ memory: log_mode: "console" # choose from [console, file] provider: "in-house" # Options: in-house, mem0, zep + # Personalization settings (all opt-in, disabled by default) + # auto_extract: saves conversation turns as memories after each chat LLM call + # auto_inject: retrieves relevant memories and prepends them before each chat LLM call + auto_extract: false + auto_inject: false + relevance_threshold: 0.5 # Minimum similarity score for memory injection + max_injected_memories: 5 # Max memories to inject per LLM call + max_memory_tokens: 1500 # Token budget for injected memory block + # Mem0 provider configuration (used when provider: "mem0") mem0: api_key: "" # Optional: for Mem0 cloud @@ -83,6 +92,7 @@ memory: provider: "chroma" config: collection_name: "mem0_memories" + # path: ".mem0/chroma" # ChromaDB persistence directory (default: .mem0/chroma) # Zep provider configuration (used when provider: "zep") zep: diff --git a/aios/hooks/modules/agent.py b/aios/hooks/modules/agent.py index 3d3cd164e..546a075e6 100644 --- a/aios/hooks/modules/agent.py +++ b/aios/hooks/modules/agent.py @@ -18,7 +18,7 @@ def useFactory( thread_pool = ThreadPoolExecutor(max_workers=params.max_workers) manager = AgentManager('https://app.aios.foundation') - send_request, _ = useSysCall() + send_request, _, _ = useSysCall() @validate(AgentSubmitDeclaration) def submitAgent(declaration_params: AgentSubmitDeclaration) -> int: diff --git a/aios/memory/context_injector.py b/aios/memory/context_injector.py new file mode 100644 index 000000000..55070dcc7 --- /dev/null +++ b/aios/memory/context_injector.py @@ -0,0 +1,233 @@ +""" +Context Injector for the AIOS personalization pipeline. + +Retrieves relevant memories from the configured memory provider and +prepends them as a system message to the LLM query's message list, +enabling personalized agent responses based on prior interactions. +""" +import logging +from typing import TYPE_CHECKING, Optional + +from cerebrum.llm.apis import LLMQuery +from cerebrum.memory.apis import MemoryQuery + +if TYPE_CHECKING: + from aios.memory.manager import MemoryManager + +logger = logging.getLogger(__name__) + + +class ContextInjector: + """Retrieves relevant memories and injects them into LLM + query messages. + + Uses the memory provider's ``retrieve_memory`` operation to + find memories scoped to the requesting agent, filters by + relevance score, formats them into a delimited system message, + and prepends it at index 0 of the query's message list. + """ + + def __init__( + self, + memory_manager: "MemoryManager", + config: dict, + ) -> None: + """ + Args: + memory_manager: Initialized MemoryManager instance. + config: Memory config section from config.yaml. + """ + self.memory_manager = memory_manager + self.enabled = config.get("auto_inject", False) + self.max_memories = config.get( + "max_injected_memories", 5 + ) + self.relevance_threshold = config.get( + "relevance_threshold", 0.5 + ) + self.max_tokens = config.get("max_memory_tokens", 1500) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def inject( + self, + agent_name: str, + query: LLMQuery, + ) -> LLMQuery: + """Retrieve relevant memories and prepend as a system + message. + + Returns the query unmodified when: + - ``auto_inject`` is disabled + - no user-role messages exist in the query + - no memories survive filtering + - any exception occurs during retrieval or formatting + """ + if not self.enabled: + return query + + try: + user_text = self._extract_latest_user_message( + query.messages + ) + if user_text is None: + return query + + # Retrieve memories scoped to this agent + mem_query = MemoryQuery( + operation_type="retrieve_memory", + params={ + "content": user_text, + "k": self.max_memories, + "user_id": agent_name, + }, + ) + response = ( + self.memory_manager.provider.retrieve_memory( + mem_query + ) + ) + + if ( + not response.success + or not response.search_results + ): + logger.info( + "No memories retrieved for user_id=%s", + agent_name, + ) + return query + + results = response.search_results + logger.info( + "Retrieved %d memories for user_id=%s", + len(results), + agent_name, + ) + + # Filter by relevance threshold + filtered = [] + for mem in results: + score = mem.get("score") + if score is None: + # No score from provider — include by default + filtered.append(mem) + elif score >= self.relevance_threshold: + filtered.append(mem) + else: + logger.debug( + "Excluded memory (score=%.3f < " + "threshold=%.3f): %s", + score, + self.relevance_threshold, + (mem.get("content", ""))[:60], + ) + + if not filtered: + logger.info( + "All memories excluded by relevance " + "threshold for user_id=%s", + agent_name, + ) + return query + + # Sort by score descending (most relevant first) + filtered.sort( + key=lambda m: m.get("score", 0), + reverse=True, + ) + + # Truncate by token budget, removing least + # relevant first + filtered = self._truncate_by_token_budget( + filtered + ) + + if not filtered: + return query + + # Build and prepend the system message + block = self._format_memory_block(filtered) + system_msg = { + "role": "system", + "content": block, + } + query.messages = [system_msg] + query.messages + + logger.info( + "Injected %d memories for user_id=%s", + len(filtered), + agent_name, + ) + return query + + except Exception: + logger.warning( + "Context injection failed for " + "user_id=%s", + agent_name, + exc_info=True, + ) + return query + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _extract_latest_user_message( + messages: list, + ) -> Optional[str]: + """Return the content of the last user-role message, + or ``None`` if none exists.""" + for msg in reversed(messages): + if msg.get("role") == "user": + return msg.get("content") + return None + + @staticmethod + def _format_memory_block( + memories: list, + ) -> str: + """Format memories into a delimited system message + string.""" + lines = [ + "===== MEMORY CONTEXT =====", + "The following are relevant memories from prior " + "interactions with this user. Use them to " + "personalize your response:", + "", + ] + for mem in memories: + ts = mem.get("timestamp", "unknown") + content = mem.get("content", "") + lines.append(f"- [{ts}] {content}") + + lines.append("") + lines.append("===== END MEMORY CONTEXT =====") + return "\n".join(lines) + + @staticmethod + def _estimate_tokens(text: str) -> int: + """Rough token estimate: words * 1.3.""" + return int(len(text.split()) * 1.3) + + def _truncate_by_token_budget( + self, + memories: list, + ) -> list: + """Remove least-relevant memories until the formatted + block fits within ``max_tokens``. + + Memories are assumed to be sorted by score descending. + We remove from the tail (lowest score) first. + """ + while memories: + block = self._format_memory_block(memories) + if self._estimate_tokens(block) <= self.max_tokens: + return memories + # Drop the least relevant (last item) + memories = memories[:-1] + return memories diff --git a/aios/memory/conversation_extractor.py b/aios/memory/conversation_extractor.py new file mode 100644 index 000000000..7d3d9337c --- /dev/null +++ b/aios/memory/conversation_extractor.py @@ -0,0 +1,120 @@ +""" +Conversation Extractor for the AIOS personalization pipeline. + +Extracts user+assistant conversation pairs from completed LLM turns +and stores them as memories via the configured memory provider. +Runs asynchronously in a daemon thread so it never blocks LLM responses. +""" +import logging +import threading +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from aios.memory.manager import MemoryManager + +logger = logging.getLogger(__name__) + + +class ConversationExtractor: + """Extracts conversation turns and stores them as memories. + + Uses the memory provider's ``add_memory`` operation (never + ``add_agentic_memory``) to persist conversation pairs scoped + to the originating agent name. + """ + + def __init__( + self, + memory_manager: "MemoryManager", + config: dict, + ) -> None: + """ + Args: + memory_manager: Initialized MemoryManager instance. + config: Memory config section from config.yaml. + """ + self.memory_manager = memory_manager + self.enabled = config.get("auto_extract", False) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def extract_async( + self, + agent_name: str, + user_message: str, + assistant_message: str, + ) -> None: + """Store a conversation pair in a background daemon thread. + + No-op when ``auto_extract`` is disabled. Errors are logged + at WARNING level and never raised. + """ + if not self.enabled: + return + + try: + thread = threading.Thread( + target=self._store_conversation, + args=(agent_name, user_message, assistant_message), + daemon=True, + ) + thread.start() + except Exception: + logger.warning( + "Failed to spawn extraction thread", + exc_info=True, + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _store_conversation( + self, + agent_name: str, + user_message: str, + assistant_message: str, + ) -> None: + """Synchronous storage called from the background thread.""" + try: + from aios.memory.note import MemoryNote + + content = self._build_conversation_content( + user_message, assistant_message, + ) + + memory_note = MemoryNote( + content=content, + context="conversation", + category="conversation", + ) + # Attach provider-specific metadata so Mem0Provider + # picks up the correct user_id scope. + memory_note.metadata = {"user_id": agent_name} + + result = self.memory_manager.provider.add_memory( + memory_note + ) + + logger.info( + "Stored conversation memory for user_id=%s: %s", + agent_name, + content[:80], + ) + except Exception as e: + logger.warning( + "Conversation extraction failed for " + "user_id=%s", + agent_name, + exc_info=True, + ) + + @staticmethod + def _build_conversation_content( + user_message: str, + assistant_message: str, + ) -> str: + """Format the conversation pair for storage.""" + return f"User: {user_message}\nAssistant: {assistant_message}" diff --git a/aios/memory/providers/mem0.py b/aios/memory/providers/mem0.py index 0bf967bae..d6334f48d 100644 --- a/aios/memory/providers/mem0.py +++ b/aios/memory/providers/mem0.py @@ -5,6 +5,8 @@ management capabilities including automatic memory extraction, semantic search, and intelligent memory organization. """ +import logging +import os from typing import Dict, Any, List, TYPE_CHECKING from cerebrum.memory.apis import MemoryQuery, MemoryResponse @@ -14,6 +16,8 @@ if TYPE_CHECKING: from aios.memory.note import MemoryNote +logger = logging.getLogger(__name__) + class Mem0Provider(MemoryProvider): """Provider using Mem0 for memory management. @@ -42,7 +46,9 @@ def initialize(self, config: Dict[str, Any]) -> None: """Initialize the provider with Mem0 configuration. Creates and configures the Mem0 Memory client with the provided - settings for LLM, embedder, and vector store. + settings for LLM, embedder, and vector store. Resolves API keys + for cloud providers (OpenAI, Anthropic) from ConfigManager or + environment variables. Args: config: Configuration dictionary containing: @@ -60,7 +66,8 @@ def initialize(self, config: Dict[str, Any]) -> None: from mem0 import Memory except ImportError as e: raise ImportError( - "Mem0 library not installed. Install with: pip install mem0ai" + "Mem0 library not installed. " + "Install with: pip install mem0ai" ) from e try: @@ -79,6 +86,51 @@ def initialize(self, config: Dict[str, Any]) -> None: if config.get("vector_store"): mem0_config["vector_store"] = config["vector_store"] + + # Inject default ChromaDB persistence path if missing + # and ensure any relative path is resolved to absolute + vs = mem0_config["vector_store"] + if vs.get("provider") == "chroma": + vs_cfg = vs.setdefault("config", {}) + if not vs_cfg.get("path"): + path = os.path.join( + os.getcwd(), ".mem0", "chroma" + ) + vs_cfg["path"] = path + else: + path = vs_cfg["path"] + # Always resolve to absolute path + vs_cfg["path"] = os.path.abspath(path) + os.makedirs(vs_cfg["path"], exist_ok=True) + logger.info( + "ChromaDB persistence path: %s", + vs_cfg["path"], + ) + + # Inject a PersistentClient to bypass Mem0's + # deprecated chromadb.Client(Settings(...)) which + # is always in-memory in ChromaDB >= 0.4 + if not vs_cfg.get("client"): + import chromadb + from chromadb.config import ( + Settings as ChromaSettings, + ) + vs_cfg["client"] = ( + chromadb.PersistentClient( + path=vs_cfg["path"], + settings=ChromaSettings( + anonymized_telemetry=False, + ), + ) + ) + logger.info( + "Injected PersistentClient for " + "ChromaDB at %s", + vs_cfg["path"], + ) + + # Resolve API keys for cloud LLM/embedder providers + self._resolve_provider_api_keys(mem0_config) # Initialize Mem0 client if mem0_config: @@ -94,6 +146,79 @@ def initialize(self, config: Dict[str, Any]) -> None: f"Failed to initialize Mem0 client: {str(e)}" ) + def _resolve_provider_api_keys( + self, mem0_config: Dict[str, Any] + ) -> None: + """Resolve API keys for cloud providers and inject into config. + + For LLM providers "openai" and "anthropic", and embedder provider + "openai", resolves the API key from ConfigManager or the + corresponding environment variable and injects it into the + Mem0 config dict. + + Args: + mem0_config: The Mem0 configuration dict to modify in-place. + """ + # Provider name -> (config key name, env var name) + _KEY_MAP = { + "openai": ("openai_api_key", "OPENAI_API_KEY"), + "anthropic": ("anthropic_api_key", "ANTHROPIC_API_KEY"), + } + + # Resolve LLM provider API key + llm_cfg = mem0_config.get("llm", {}) + llm_provider = llm_cfg.get("provider", "") + if llm_provider in _KEY_MAP: + key = self._get_api_key(llm_provider) + if key: + cfg_key, env_var = _KEY_MAP[llm_provider] + llm_cfg.setdefault("config", {})[cfg_key] = key + logger.info( + "Resolved API key for LLM provider '%s'", + llm_provider, + ) + + # Resolve embedder provider API key + embedder_cfg = mem0_config.get("embedder", {}) + embedder_provider = embedder_cfg.get("provider", "") + if embedder_provider in _KEY_MAP: + key = self._get_api_key(embedder_provider) + if key: + cfg_key, _ = _KEY_MAP[embedder_provider] + embedder_cfg.setdefault("config", {})[cfg_key] = key + logger.info( + "Resolved API key for embedder provider '%s'", + embedder_provider, + ) + + @staticmethod + def _get_api_key(provider: str) -> str | None: + """Retrieve API key from ConfigManager or environment variable. + + Args: + provider: Provider name (e.g. "openai", "anthropic"). + + Returns: + The API key string, or None if not found. + """ + try: + from aios.config.config_manager import config as global_config + key = global_config.get_api_key(provider) + if key: + return key + except Exception: + pass + + # Fallback: check environment variable directly + env_map = { + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + } + env_var = env_map.get(provider) + if env_var: + return os.environ.get(env_var) + return None + def add_memory(self, memory_note: 'MemoryNote') -> MemoryResponse: """Add a memory note to Mem0 storage. diff --git a/aios/syscall/syscall.py b/aios/syscall/syscall.py index 661aa5356..a7e0c06e7 100755 --- a/aios/syscall/syscall.py +++ b/aios/syscall/syscall.py @@ -46,6 +46,8 @@ def __init__(self): """Initialize the SyscallExecutor.""" self.id = 0 self.id_lock = threading.Lock() + self.context_injector = None + self.conversation_extractor = None def create_syscall(self, agent_name: str, query) -> Dict[str, Any]: """ @@ -633,6 +635,15 @@ def execute_memory_evolve(self, query: MemoryQuery, similar_memories: List[Memor return (query, similar_memories) + @staticmethod + def _get_latest_user_message(messages) -> Optional[str]: + """Return the content of the last user-role message, + or ``None`` if none exists.""" + for msg in reversed(messages): + if msg.get("role") == "user": + return msg.get("content") + return None + def execute_request(self, agent_name: str, query: Any) -> Dict[str, Any]: """ Execute a request based on its type. @@ -652,7 +663,28 @@ def execute_request(self, agent_name: str, query: Any) -> Dict[str, Any]: """ if isinstance(query, LLMQuery): # breakpoint() - if query.action_type == "chat" or query.action_type == "chat_with_json_output" or query.action_type == "chat_with_tool_call_output": + if query.action_type in ("chat", "chat_with_tool_call_output"): + # Context injection (before LLM call) + if self.context_injector: + query = self.context_injector.inject(agent_name, query) + + llm_response = self.execute_llm_syscall(agent_name, query) + + # Conversation extraction (after LLM call, async) + if (self.conversation_extractor + and query.action_type == "chat"): + user_msg = self._get_latest_user_message(query.messages) + assistant_msg = llm_response.get("response") + if hasattr(assistant_msg, "response_message"): + assistant_msg = assistant_msg.response_message + if user_msg and assistant_msg: + self.conversation_extractor.extract_async( + agent_name, user_msg, str(assistant_msg) + ) + + return llm_response + + elif query.action_type == "chat_with_json_output": llm_response = self.execute_llm_syscall(agent_name, query) return llm_response @@ -710,12 +742,13 @@ def create_syscall_executor(): Create and return a SyscallExecutor instance and its wrapper. Returns: - Tuple of (execute_request function, SyscallWrapper class) + Tuple of (execute_request function, SyscallWrapper class, + executor instance) Example: ```python - executor, wrapper = create_syscall_executor() - response = executor("agent_1", LLMQuery(...)) + execute_request, wrapper, executor = create_syscall_executor() + response = execute_request("agent_1", LLMQuery(...)) ``` """ executor = SyscallExecutor() @@ -727,7 +760,7 @@ class SyscallWrapper: memory = executor.execute_memory_syscall tool = executor.execute_tool_syscall - return executor.execute_request, SyscallWrapper + return executor.execute_request, SyscallWrapper, executor # Maintain backwards compatibility useSysCall = create_syscall_executor diff --git a/aios/terminal/__init__.py b/aios/terminal/__init__.py new file mode 100644 index 000000000..2ffd1adc2 --- /dev/null +++ b/aios/terminal/__init__.py @@ -0,0 +1,17 @@ +"""Terminal intent routing for AIOS.""" + +from aios.terminal.intent_router import ( + IntentRouter, + Intent, + Confidence, + ClassificationResult, + build_llm_classify_fn, +) + +__all__ = [ + "IntentRouter", + "Intent", + "Confidence", + "ClassificationResult", + "build_llm_classify_fn", +] diff --git a/aios/terminal/intent_router.py b/aios/terminal/intent_router.py new file mode 100644 index 000000000..eff44fb5d --- /dev/null +++ b/aios/terminal/intent_router.py @@ -0,0 +1,223 @@ +"""Intent classification for terminal input routing. + +Provides a two-stage classifier (keyword heuristic + LLM +fallback) that determines whether user input is a chat +message or a file operation request. +""" + +from dataclasses import dataclass +from enum import Enum +from cerebrum.llm.apis import llm_chat + + +class Intent(Enum): + CHAT = "chat" + FILE_OPERATION = "file_operation" + + +class Confidence(Enum): + HIGH = "high" + AMBIGUOUS = "ambiguous" + + +@dataclass +class ClassificationResult: + intent: Intent + confidence: Confidence + source: str # "keyword" or "llm" + + +class IntentRouter: + """Two-stage intent classifier: keyword heuristic + with LLM fallback.""" + + # Confidence threshold: ratio of file-score to + # chat-score (or vice versa) must exceed this to + # be considered HIGH confidence. + CONFIDENCE_THRESHOLD = 2.0 + + FILE_VERBS = { + "create", "write", "delete", "list", "read", + "rollback", "share", "mount", "rename", "move", + "copy", "remove", + } + FILE_NOUNS = { + "file", "files", "directory", "directories", + "folder", "folders", "path", "paths", + } + CHAT_GREETINGS = { + "hello", "hi", "hey", "greetings", + "good morning", "good afternoon", + "good evening", "howdy", + } + CHAT_PERSONAL = { + "my name is", "i like", "i prefer", + "i am", "i'm", "i enjoy", "i love", + "i hate", "i want", "i need", + } + + def __init__( + self, + llm_classify_fn=None, + ): + """ + Args: + llm_classify_fn: Optional callable + (str) -> Intent for LLM fallback. + If None, ambiguous inputs default to + Intent.CHAT. + """ + self.llm_classify_fn = llm_classify_fn + + def classify(self, user_input: str) -> ClassificationResult: + """Classify user input as chat or file operation. + + Runs keyword heuristic first. Falls back to LLM + classifier when confidence is ambiguous. + """ + result = self._keyword_classify(user_input) + if result.confidence == Confidence.HIGH: + return result + return self._llm_classify(user_input) + + def _keyword_classify( + self, user_input: str, + ) -> ClassificationResult: + """Rule-based classification using keyword + pattern matching and confidence scoring.""" + text = user_input.lower().strip() + file_score = self._file_score(text) + chat_score = self._chat_score(text) + + if file_score == 0 and chat_score == 0: + return ClassificationResult( + Intent.CHAT, Confidence.AMBIGUOUS, "keyword", + ) + + if file_score > 0 and chat_score > 0: + ratio = max(file_score, chat_score) / max( + min(file_score, chat_score), 0.1, + ) + if ratio < self.CONFIDENCE_THRESHOLD: + return ClassificationResult( + Intent.CHAT + if chat_score >= file_score + else Intent.FILE_OPERATION, + Confidence.AMBIGUOUS, + "keyword", + ) + + if file_score > chat_score: + return ClassificationResult( + Intent.FILE_OPERATION, + Confidence.HIGH, + "keyword", + ) + return ClassificationResult( + Intent.CHAT, Confidence.HIGH, "keyword", + ) + + def _file_score(self, text: str) -> float: + """Score how likely the input is a file operation. + + Requires at least one file verb AND one file noun + for a positive score. Individual matches without + the other category score lower. + """ + words = set(text.split()) + verb_hits = words & self.FILE_VERBS + noun_hits = words & self.FILE_NOUNS + score = 0.0 + if verb_hits and noun_hits: + score = len(verb_hits) + len(noun_hits) + elif verb_hits: + score = len(verb_hits) * 0.3 + elif noun_hits: + score = len(noun_hits) * 0.3 + return score + + def _chat_score(self, text: str) -> float: + """Score how likely the input is conversational.""" + score = 0.0 + # Greeting match + for greeting in self.CHAT_GREETINGS: + if text.startswith(greeting): + score += 2.0 + break + # Personal info match + for pattern in self.CHAT_PERSONAL: + if pattern in text: + score += 1.5 + break + # Question not about files + if text.endswith("?"): + words = set(text.split()) + if not (words & self.FILE_NOUNS): + score += 1.0 + return score + + def _llm_classify( + self, user_input: str, + ) -> ClassificationResult: + """LLM-based fallback classification.""" + if self.llm_classify_fn is None: + return ClassificationResult( + Intent.CHAT, Confidence.AMBIGUOUS, "keyword", + ) + try: + intent = self.llm_classify_fn(user_input) + return ClassificationResult( + intent, Confidence.HIGH, "llm", + ) + except Exception: + # Fail-open to chat + return ClassificationResult( + Intent.CHAT, Confidence.AMBIGUOUS, "llm", + ) + + +LLM_CLASSIFY_SYSTEM_PROMPT = """\ +You are an intent classifier for a terminal application. +Classify the user's message into exactly one category: + +- "chat": conversational messages, greetings, questions \ +about general topics, personal information sharing, \ +opinion requests, or anything not related to file \ +system operations. +- "file_operation": requests to create, read, write, \ +delete, list, rename, move, copy, rollback, share, \ +or mount files, directories, or paths. + +Respond with ONLY the category name, nothing else. +No explanation, no punctuation, no quotes. +""" + + +def build_llm_classify_fn(agent_name, base_url=None): + """Build an LLM classification callable for the + IntentRouter. + + Returns a function (str) -> Intent that sends the + user input to the LLM with the classification prompt. + """ + def classify(user_input: str) -> Intent: + response = llm_chat( + agent_name=agent_name, + messages=[ + {"role": "system", + "content": LLM_CLASSIFY_SYSTEM_PROMPT}, + {"role": "user", "content": user_input}, + ], + **({"base_url": base_url} if base_url else {}), + ) + raw = str( + response.get("response", "") + ).strip().lower() + # Extract the response_message if present + if hasattr(raw, "response_message"): + raw = raw.response_message.strip().lower() + if "file" in raw: + return Intent.FILE_OPERATION + return Intent.CHAT + + return classify diff --git a/runtime/launch.py b/runtime/launch.py index d8d553d61..79a7dca39 100644 --- a/runtime/launch.py +++ b/runtime/launch.py @@ -21,6 +21,8 @@ from aios.syscall.syscall import useSysCall from aios.config.config_manager import config +from aios.memory.context_injector import ContextInjector +from aios.memory.conversation_extractor import ConversationExtractor from cerebrum.llm.apis import LLMQuery, LLMResponse @@ -64,7 +66,7 @@ "llms": [] } -execute_request, SysCallWrapper = useSysCall() +execute_request, SysCallWrapper, syscall_executor = useSysCall() # Configure the root logger logging.basicConfig( @@ -302,6 +304,34 @@ def initialize_components() -> dict: print(memory_config) components["memory"] = initialize_memory_manager(memory_config, components["storage"]) print("memory manager: ", components["memory"]) + + # Wire up personalization components for mem0 provider + provider_type = memory_config.get("provider", "in-house") + if provider_type == "mem0" and components["memory"]: + try: + syscall_executor.context_injector = ( + ContextInjector( + components["memory"], memory_config + ) + ) + syscall_executor.conversation_extractor = ( + ConversationExtractor( + components["memory"], memory_config + ) + ) + print( + "✅ Personalization components " + "(ContextInjector, ConversationExtractor) " + "initialized" + ) + except Exception as e: + print( + f"⚠️ Personalization setup failed " + f"(non-fatal): {str(e)}" + ) + syscall_executor.context_injector = None + syscall_executor.conversation_extractor = None + components["tool"] = initialize_tool_manager() # Verify required components @@ -325,7 +355,16 @@ def initialize_components() -> dict: raise # Initialize components when starting up -active_components = initialize_components() +active_components = None + +def _ensure_initialized(): + global active_components + if active_components is None: + active_components = initialize_components() + return active_components + +# Initialize on first import (uvicorn worker) +_ensure_initialized() def restart_kernel(): """Restart kernel service and reload configuration""" @@ -764,4 +803,4 @@ async def update_config(request: Request): port = server_config.get("port", 8000) # print(f"Starting AIOS server on {host}:{port}") - uvicorn.run("runtime.launch:app", host=host, port=port, reload=False) \ No newline at end of file + uvicorn.run(app, host=host, port=port) \ No newline at end of file diff --git a/runtime/run_terminal.py b/runtime/run_terminal.py index 4e072ee83..76e0a64ac 100644 --- a/runtime/run_terminal.py +++ b/runtime/run_terminal.py @@ -25,6 +25,12 @@ from cerebrum.llm.apis import LLMQuery, LLMResponse, llm_call_tool, llm_chat, llm_operate_file +from aios.terminal.intent_router import ( + IntentRouter, + Intent, + build_llm_classify_fn, +) + class AIOSTerminal: def __init__(self): self.console = Console() @@ -39,13 +45,23 @@ def __init__(self): self.current_dir = os.getcwd() + self.mode = "auto" + self.conversation_history = [] + llm_fn = build_llm_classify_fn( + "terminal", + base_url=config.get('kernel', 'base_url'), + ) + self.router = IntentRouter(llm_classify_fn=llm_fn) + # self.storage_client = StorageClient() def get_prompt(self, extra_str = None): username = os.getenv('USER', 'user') path = os.path.basename(self.current_dir) + mode_indicator = f'[{self.mode}] ' if extra_str: return [ + ('class:prompt', mode_indicator), ('class:prompt', f'🚀 {username}'), ('class:arrow', ' ⟹ '), ('class:path', f'{path}'), @@ -54,6 +70,7 @@ def get_prompt(self, extra_str = None): ] else: return [ + ('class:prompt', mode_indicator), ('class:prompt', f'🚀 {username}'), ('class:arrow', ' ⟹ '), ('class:path', f'{path}'), @@ -68,11 +85,82 @@ def display_help(self): # Add command descriptions help_table.add_row("help", "Show this help message") help_table.add_row("exit", "Exit the terminal") + help_table.add_row("/chat", "Switch to chat mode (all input → chat)") + help_table.add_row("/file", "Switch to file mode (all input → file ops)") + help_table.add_row("/auto", "Switch to auto mode (intent routing)") # help_table.add_row("list agents --offline", "List all available offline agents") help_table.add_row("list agents --online", "List all available agents on the agenthub") - help_table.add_row("", "Execute semantic file operations using natural language") + help_table.add_row("", "Routed automatically based on current mode") self.console.print(Panel(help_table, title="Available Commands", border_style="blue")) + self.console.print(f"\nCurrent mode: [bold]{self.mode}[/bold]") + + def handle_slash_command(self, command): + """Returns True if command was a slash command.""" + cmd = command.strip().lower() + if cmd == "/chat": + self.mode = "chat" + self.console.print("[cyan]Switched to chat mode[/cyan]") + return True + if cmd == "/file": + self.mode = "file" + self.console.print("[cyan]Switched to file mode[/cyan]") + return True + if cmd == "/auto": + self.mode = "auto" + self.console.print("[cyan]Switched to auto mode[/cyan]") + return True + return False + + def route_input(self, user_input): + """Dispatch user input based on current mode.""" + if self.mode == "chat": + return self._send_chat(user_input) + if self.mode == "file": + return self._send_file(user_input) + # auto mode + result = self.router.classify(user_input) + self.console.print( + f"[dim][{result.intent.value}][/dim]" + ) + if result.intent == Intent.CHAT: + return self._send_chat(user_input) + return self._send_file(user_input) + + def _send_chat(self, user_input): + """Send input through the chat/personalization pipeline.""" + self.conversation_history.append( + {"role": "user", "content": user_input} + ) + response = llm_chat( + agent_name="terminal", + messages=list(self.conversation_history), + base_url=config.get('kernel', 'base_url'), + ) + resp = response.get("response", "") + if isinstance(resp, dict): + assistant_msg = resp.get( + "response_message", str(resp) + ) + elif hasattr(resp, "response_message"): + assistant_msg = resp.response_message + else: + assistant_msg = str(resp) + self.conversation_history.append( + {"role": "assistant", "content": assistant_msg} + ) + return assistant_msg + + def _send_file(self, user_input): + """Send input through the file operation pipeline.""" + return llm_operate_file( + agent_name="terminal", + messages=[ + {"role": "user", "content": user_input} + ], + tools=[], + base_url=config.get('kernel', 'base_url'), + ) def handle_list_agents(self, args: str): """Handle the 'list agents' command with different parameters. @@ -135,9 +223,10 @@ def run(self): self.handle_list_agents(args) continue - command_response = llm_operate_file( - agent_name="terminal", messages=[{"role": "user", "content": command}], tools=[], base_url=config.get('kernel', 'base_url') - ) + if self.handle_slash_command(command): + continue + + command_response = self.route_input(command) command_output = Text(command_response, style="bold green") self.console.print(command_output) diff --git a/scripts/run_terminal.py b/scripts/run_terminal.py index 94892d24c..a84bc9777 100644 --- a/scripts/run_terminal.py +++ b/scripts/run_terminal.py @@ -6,6 +6,13 @@ from rich.panel import Panel from rich.text import Text import os +import sys + +# Ensure the project root is on the path so aios +# can be imported when running from scripts/ +sys.path.insert( + 0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +) from pydantic import BaseModel from typing import Optional, Dict, Any, List from enum import Enum @@ -16,6 +23,12 @@ from cerebrum.storage.apis import mount +from aios.terminal.intent_router import ( + IntentRouter, + Intent, + build_llm_classify_fn, +) + class AIOSTerminal: def __init__(self): self.console = Console() @@ -30,13 +43,20 @@ def __init__(self): self.current_dir = os.getcwd() + self.mode = "auto" + self.conversation_history = [] + llm_fn = build_llm_classify_fn("terminal") + self.router = IntentRouter(llm_classify_fn=llm_fn) + # self.storage_client = StorageClient() - def get_prompt(self, extra_str = None): + def get_prompt(self, extra_str=None): username = os.getenv('USER', 'user') path = os.path.basename(self.current_dir) + mode_indicator = f'[{self.mode}] ' if extra_str: return [ + ('class:prompt', mode_indicator), ('class:prompt', f'🚀 {username}'), ('class:arrow', ' ⟹ '), ('class:path', f'{path}'), @@ -45,6 +65,7 @@ def get_prompt(self, extra_str = None): ] else: return [ + ('class:prompt', mode_indicator), ('class:prompt', f'🚀 {username}'), ('class:arrow', ' ⟹ '), ('class:path', f'{path}'), @@ -59,11 +80,79 @@ def display_help(self): # Add command descriptions help_table.add_row("help", "Show this help message") help_table.add_row("exit", "Exit the terminal") + help_table.add_row("/chat", "Switch to chat mode (all input → chat)") + help_table.add_row("/file", "Switch to file mode (all input → file ops)") + help_table.add_row("/auto", "Switch to auto mode (intent routing)") # help_table.add_row("list agents --offline", "List all available offline agents") help_table.add_row("list agents --online", "List all available agents on the agenthub") - help_table.add_row("", "Execute semantic file operations using natural language") + help_table.add_row("", "Routed automatically based on current mode") self.console.print(Panel(help_table, title="Available Commands", border_style="blue")) + self.console.print(f"\nCurrent mode: [bold]{self.mode}[/bold]") + + def handle_slash_command(self, command): + """Returns True if command was a slash command.""" + cmd = command.strip().lower() + if cmd == "/chat": + self.mode = "chat" + self.console.print("[cyan]Switched to chat mode[/cyan]") + return True + if cmd == "/file": + self.mode = "file" + self.console.print("[cyan]Switched to file mode[/cyan]") + return True + if cmd == "/auto": + self.mode = "auto" + self.console.print("[cyan]Switched to auto mode[/cyan]") + return True + return False + + def route_input(self, user_input): + """Dispatch user input based on current mode.""" + if self.mode == "chat": + return self._send_chat(user_input) + if self.mode == "file": + return self._send_file(user_input) + # auto mode + result = self.router.classify(user_input) + self.console.print( + f"[dim][{result.intent.value}][/dim]" + ) + if result.intent == Intent.CHAT: + return self._send_chat(user_input) + return self._send_file(user_input) + + def _send_chat(self, user_input): + """Send input through the chat/personalization pipeline.""" + self.conversation_history.append( + {"role": "user", "content": user_input} + ) + response = llm_chat( + agent_name="terminal", + messages=list(self.conversation_history), + ) + resp = response.get("response", "") + if isinstance(resp, dict): + assistant_msg = resp.get( + "response_message", str(resp) + ) + elif hasattr(resp, "response_message"): + assistant_msg = resp.response_message + else: + assistant_msg = str(resp) + self.conversation_history.append( + {"role": "assistant", "content": assistant_msg} + ) + return assistant_msg + + def _send_file(self, user_input): + """Send input through the file operation pipeline.""" + return llm_operate_file( + agent_name="terminal", + messages=[ + {"role": "user", "content": user_input} + ], + ) def handle_list_agents(self, args: str): """Handle the 'list agents' command with different parameters. @@ -126,7 +215,10 @@ def run(self): self.handle_list_agents(args) continue - command_response = llm_operate_file(agent_name="terminal", messages=[{"role": "user", "content": command}]) + if self.handle_slash_command(command): + continue + + command_response = self.route_input(command) command_output = Text(command_response, style="bold green") self.console.print(command_output) diff --git a/tests/modules/memory/__init__.py b/tests/modules/memory/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/modules/memory/__init__.py @@ -0,0 +1 @@ +