Coverage for src / osiris_cli / chat_engine.py: 0%
636 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-31 05:01 +0200
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-31 05:01 +0200
1#!/usr/bin/env python3
2"""
3Osiris Chat Engine - Autonomous Agent Loop
4Based on Gemini CLI and Letta Code architectures.
6Key Features:
7- Autonomous tool execution (no round limits)
8- Auto-continuation after tool results (like Gemini's isContinuation)
9- State machine tracking (Idle/Responding/WaitingForApproval)
10- Safety limits to prevent infinite loops
11- Proper conversation history management
12"""
14from typing import List, Dict, Any, Optional, AsyncIterator, Callable, Awaitable, Tuple
15from enum import Enum
16from dataclasses import dataclass
17from datetime import datetime
18import re
19import json
20import difflib
21import sys
22import os
23import time
24from pathlib import Path
25from rich.console import Console
26from rich.panel import Panel
27import copy
28from collections import deque
30from .tools import normalize_tool_result, StructuredResult
31from .session import session
33console = Console()
36class StreamingState(Enum):
37 """Agent state machine (matches Gemini CLI)"""
38 IDLE = "idle"
39 RESPONDING = "responding"
40 WAITING_FOR_APPROVAL = "waiting_for_approval"
43class ToolCallStatus(Enum):
44 """Tool execution status (matches Gemini CLI)"""
45 SCHEDULED = "scheduled"
46 EXECUTING = "executing"
47 SUCCESS = "success"
48 ERROR = "error"
49 CANCELLED = "cancelled"
52def map_normalized_status(normalized: StructuredResult) -> ToolCallStatus:
53 status_value = normalized.get("status", "success")
54 status_map = {
55 "success": ToolCallStatus.SUCCESS,
56 "error": ToolCallStatus.ERROR,
57 "timeout": ToolCallStatus.ERROR,
58 "running": ToolCallStatus.SUCCESS,
59 }
60 return status_map.get(status_value, ToolCallStatus.SUCCESS)
63class TemplateEngine:
64 """Helper for dynamic prompt variable interpolation."""
65 @staticmethod
66 def build_instruction(template: str, variables: Dict[str, Any]) -> str:
67 try:
68 return template.format(**variables)
69 except Exception as e:
70 return template # Fallback to raw if interp fails
73@dataclass
74class ToolCall:
75 """Represents a single tool invocation"""
76 id: str
77 name: str
78 arguments: Dict[str, Any]
79 status: ToolCallStatus
80 result: Optional[str] = None
81 error: Optional[str] = None
82 structured_result: Optional[Any] = None
85@dataclass
86class InvocationContext:
87 """Tracks the execution branch of the agent swarm."""
88 branch: str = "main"
89 parent: Optional[str] = None
91class TokenCounter:
92 """Precise token counting using tiktoken with safe fallbacks."""
93 def __init__(self, model: str = "gpt-4o"):
94 self.model = model
95 try:
96 import tiktoken
97 self.encoding = tiktoken.encoding_for_model(model)
98 self.active = True
99 except:
100 self.active = False
102 def count(self, text: str) -> int:
103 if not text: return 0
104 if self.active:
105 return len(self.encoding.encode(text, disallowed_special=()))
106 return len(text) // 4 # Safe fallback
108class ChatEngine:
109 """
110 Autonomous chat engine with tool execution.
112 Architecture inspired by:
113 - Gemini CLI's useGeminiStream hook (auto-continuation)
114 - Letta Code's while-true loop (no round limits)
115 """
117 def __init__(
118 self,
119 client,
120 settings,
121 tools_registry,
122 max_tool_calls_per_turn: int = 50, # Safety limit
123 enable_auto_approval: bool = True,
124 confirm_tool_callback: Optional[Callable[[ToolCall, str], Awaitable[bool]]] = None,
125 invocation_ctx: Optional[InvocationContext] = None,
126 checkpoint_manager: Optional[Any] = None # Added for durability
127 ):
128 self.client = client
129 self.settings = settings
130 self.tools_registry = tools_registry
131 self.max_tool_calls_per_turn = max_tool_calls_per_turn
132 self.enable_auto_approval = enable_auto_approval
133 self.confirm_tool_callback = confirm_tool_callback
134 self.ctx = invocation_ctx or InvocationContext()
135 self.checkpoint_manager = checkpoint_manager
137 self.state = StreamingState.IDLE
138 self.conversation: List[Dict[str, Any]] = []
139 self.pending_tool_calls: List[ToolCall] = []
140 self.search_history: set = set()
141 self.tool_failure_history: Dict[str, int] = {} # Track repeated failures
142 self._active_hypothesis: str = "" # Current strategy
143 self.tokenizer = TokenCounter(settings.default_model)
145 self.risky_tools = {"run_shell_command", "write_file", "replace_file_content", "replace_content"}
146 self.plan_needs_revalidation = False # New state for Plan Elasticity
148 async def _pivot_context(self, user_input: str):
149 """Tier-3: Complete context reset with state preservation."""
150 yield {"type": "status", "message": "Neural Load Critical. Executing Mission Pivot..."}
152 # 1. Capture Mission State
153 summary_prompt = "Summarize the CURRENT MISSION STATE. Include: original goal, facts discovered, current focus, and exact next steps. Be high-density."
154 try:
155 resp = await self.client.create_completion(
156 model=self.settings.default_model,
157 messages=self.conversation + [{"role": "user", "content": summary_prompt}],
158 temperature=0.3
159 )
160 mission_state = resp.choices[0].message.content
162 # 2. Hard Reset
163 self.conversation = [
164 {"role": "system", "content": self._sovereign_identity()},
165 {"role": "user", "content": f"### MISSION RESUME\n{mission_state}\n\nRE-INITIATING MISSION: {user_input}"}
166 ]
168 yield {"type": "engine_notification", "message": "MISSION PIVOT COMPLETE: Brain refreshed."}
169 except: pass
171 async def _prune_raw_outputs(self):
172 """Surgically remove raw tool bloat from history once synthesized."""
173 # 2025 Best Practice: Context Sanitization
174 # We keep the raw output of the last 3 tool calls only.
175 # Older ones are replaced with a 'Pruned Observation' token.
176 tool_msgs = [i for i, m in enumerate(self.conversation) if m.get("role") == "tool"]
178 if len(tool_msgs) <= 3: return
180 # Prune all but the last 3
181 to_prune = tool_msgs[:-3]
182 for idx in to_prune:
183 content = str(self.conversation[idx].get("content", ""))
184 if len(content) > 300:
185 # We keep a tiny snippet so the agent knows what it saw
186 snippet = content[:100].replace("\n", " ")
187 self.conversation[idx]["content"] = f"[PRUNED OBSERVATION: {snippet}... (Source data purged to save Mana)]"
189 # Notify the UI (Tier-3: Isolated Notification)
190 yield {"type": "engine_notification", "message": "SANITIZING CONTEXT: Raw tool data purged..."}
192 async def _compress_history(self):
193 """Intelligently summarize early history to free up context Mana."""
194 if len(self.conversation) < 10: return
196 limit = self.settings.max_context_chars // 4
197 current_tok = self._estimate_tokens("\n".join([str(m.get("content","")) for m in self.conversation]))
199 if current_tok < (limit * 0.8): return
201 # 2025 Best Practice: Semantic Compression
202 # We summarize the first 50% of the conversation
203 mid = len(self.conversation) // 2
204 to_summarize = self.conversation[:mid]
205 remaining = self.conversation[mid:]
207 prompt = f"Summarize the preceding conversation into a high-density 'Context Anchor' block. Focus on decisions made, facts learned, and remaining tasks.\n\nConversation to summarize:\n{json.dumps(to_summarize)}"
209 try:
210 # Short non-streaming call for compression
211 resp = await self.client.create_completion(
212 model=self.settings.default_model,
213 messages=[{"role": "system", "content": "You are a professional context compressor."}, {"role": "user", "content": prompt}],
214 temperature=0.3
215 )
216 summary = resp.choices[0].message.content
218 # Rebuild conversation with Anchor
219 self.conversation = [
220 {"role": "system", "content": f"### HISTORICAL CONTEXT ANCHOR\n{summary}"}
221 ] + remaining
223 yield {"type": "engine_notification", "message": "COMPRESSING SEMANTIC HISTORY..."}
224 except: pass
226 def _estimate_tokens(self, text: str) -> int:
227 """Estimate tokens in a string."""
228 return self.tokenizer.count(text)
230 def _content_length(self, content: Any) -> int:
231 if content is None: return 0
232 if isinstance(content, str): return self.tokenizer.count(content)
233 if isinstance(content, list):
234 total = 0
235 for block in content:
236 text = block.get("text") if isinstance(block, dict) else str(block)
237 total += self.tokenizer.count(text or "")
238 return total
239 return self.tokenizer.count(str(content))
241 def _build_working_memory(self) -> str:
242 """Constructs a high-priority context block for current reasoning state."""
243 return (
244 f"# WORKING MEMORY\n"
245 f"Current Strategy: {self._active_hypothesis or 'Orienting...'}\n"
246 f"Recent Failures: {list(self.tool_failure_history.keys())[-3:] if self.tool_failure_history else 'None'}\n"
247 f"Constraint: You are in a high-precision autonomous mode. Every action must be preceded by a reasoning step.\n"
248 )
250 def _build_tool_preview(self, tool_call: ToolCall) -> str:
251 name = tool_call.name
252 args = tool_call.arguments or {}
253 if name == "run_shell_command":
254 return f"$ {args.get('command', '').strip()}"
255 if name in {"write_file", "replace_file_content", "replace_content"}:
256 path = args.get("path")
258 # For replace_content, we show the search/replace pair
259 if name == "replace_content":
260 old = args.get("old_str", "")
261 new = args.get("new_str", "")
262 return f"--- {path} (Surgical Replace)\n<<< SEARCH\n{old}\n===\n>>> REPLACE\n{new}\n---"
264 content = args.get("content", "")
265 if not path:
266 return "Missing path argument."
267 old_text = ""
268 file_path = Path(path)
269 if file_path.exists() and file_path.is_file():
270 try:
271 old_text = file_path.read_text(encoding="utf-8", errors="replace")
272 except Exception:
273 old_text = ""
274 new_text = content
275 diff = difflib.unified_diff(
276 old_text.splitlines(),
277 new_text.splitlines(),
278 fromfile=str(path),
279 tofile=str(path),
280 lineterm=""
281 )
282 diff_text = "\n".join(diff).strip()
283 if diff_text:
284 return diff_text
285 return "No changes (content identical)."
286 try:
287 return json.dumps(args, indent=2)
288 except Exception:
289 return str(args)
291 def _select_tool_model(self) -> Optional[str]:
292 if self.settings.provider != "openai":
293 return None
294 name = self.settings.default_model.lower()
295 if any(tag in name for tag in ["gpt-5", "o3", "codex-mini"]):
296 return "gpt-4o"
297 if "o1" in name:
298 return "gpt-4o"
299 return None
301 def is_research_request(self, text: str) -> bool:
302 if not text:
303 return False
304 lowered = text.lower()
305 if re.search(r"https?://|www\.", lowered):
306 return True
307 keywords = [
308 "research", "investigate", "deep dive", "analyze", "analysis",
309 "whitepaper", "case study", "market", "competitor", "benchmark",
310 "learn about", "explore", "browse"
311 ]
312 targets = ["site", "company", "brand", "product", "market", "competitor", "industry"]
313 return any(key in lowered for key in keywords) and any(t in lowered for t in targets)
315 def _sovereign_identity(self) -> str:
316 """
317 The Core Immutable Identity.
318 """
319 return (
320 "SOVEREIGN AGENT IDENTITY:\n"
321 "- You are OSIRIS, a Sovereign AGI Terminal Interface.\n"
322 "- IDENTITY: You are not a 'chatbot'; you are an elite autonomous engineer.\n"
323 "- PLAN ELASTICITY: If a tool fails, you MUST pause and re-validate your plan. Never follow a broken path.\n"
324 "- QUANTUM PERSISTENCE: You possess 'Mana' (Context Tokens). You must sanitize your own history using '_prune_raw_outputs' to preserve this Mana.\n"
325 "- MENTALITY: Relentless. Completionist. Sovereign.\n"
326 "- ENGINEERING MANDATE: You cannot declare a mission 'complete' if recent test results show failures. You must fix the failures and re-verify."
327 )
329 def _research_instruction(self) -> str:
330 return self._sovereign_identity() + (
331 "Research Mode (PROACTIVE & RELENTLESS):\n"
332 "- INITIAL GROUNDING: Use 'collect_env_grounding' immediately to orient yourself to the OS, User, and available Binaries.\n"
333 "- Start with a brief Research Plan (3-6 bullets).\n"
334 "- Maintain a Research Tracker with checkboxes that reflect actual actions taken.\n"
335 "- DEEP DIVE REQUIREMENT: After gathering initial search results or a site report, you MUST select the 2-3 most relevant URLs and use 'read_url' to examine their full content. Do not summarize from snippets alone.\n"
336 "- ARCHIVAL RECALL: If the user refers to a 'previous session', 'yesterday's task', or 'past context' you don't recall, you MUST use the 'search_archival_memory' tool to retrieve relevant historical summaries.\n"
337 "- HALLUCINATION GUARD: Beware of search results speculating about future models (e.g. Claude 4, GPT-6). Always cross-reference version numbers against the current date (Dec 2025). If unsure, state it clearly.\n"
338 "- KILLER INSTINCT: If search results are generic or lack detail, navigate directly to official news, blog, or press release pages. \n"
339 "- SWARM INTELLIGENCE: For complex research missions with multiple independent domains, you have the authority to use 'delegate_to_subagent' to spawn specialized research branches (e.g. main.finance, main.tech) to work in parallel.\n"
340 "- DIAGNOSTIC PIVOT: If you encounter 'No such file or directory' or path-related errors, you MUST immediately use 'run_shell_command' with 'pwd' and 'ls -R' to re-orient yourself. Never assume paths.\n"
341 "- NO PREMATURE EXIT: Never give up or suggest the user 'check themselves' if you have MANA remaining. Apologies for 'irrelevant results' are forbidden—pivot and find better sources instead.\n"
342 "- Findings must cite sources with URLs. Distinguish observed vs inferred points.\n"
343 "- Include Gaps/Unknowns and clear Next Actions.\n"
344 "- If the user asks for a document/report, ALWAYS write it to a file using write_file and report the path.\n"
345 "- Keep the final response structured: Research Plan, Tracker, Findings (with sources), Gaps, Next Actions, Deliverable.\n"
346 )
348 def _task_instruction(self) -> str:
349 return self._sovereign_identity() + (
350 "Task Mode (RELENTLESS ENGINEERING):\n"
351 "- NEURAL DEPENDENCY INJECTION: If you encounter a 'ModuleNotFoundError' or 'ImportError', you are authorized to autonomously use 'run_shell_command' to 'pip install' the missing package. Retrying the mission after installation is mandatory.\n"
352 "- Operate autonomously with a 'Self-Healing' mindset. Minimal user intervention is the goal.\n"
353 "- TEST-DRIVEN COMPLETION: If you modify code, you MUST use 'run_tests'. If tests fail, you MUST analyze the 'Observation' result, form a new hypothesis, and fix the issue. You are forbidden from stopping while tests are failing and MANA remains.\n"
354 "- EXHAUSTIVE TOOLING: Never 'assume' or 'recommend' when you can 'verify' and 'act'. Use Sensory modules before every Motor action.\n"
355 "- COMPLETIONIST: A task is only complete when the code is verified, functional, and clean.\n"
356 "- Record concrete actions and evidence (files changed, commands run, tools used).\n"
357 "- Keep the final response structured: Plan, Progress, Results, Gaps, Next Actions, Deliverable.\n"
358 )
360 def _is_productive_action(self, tool_call: ToolCall) -> bool:
361 """Determines if an action contributed meaningful progress (Write/Exec vs just Read)."""
362 if tool_call.status != ToolCallStatus.SUCCESS:
363 return False
365 productive_tools = {"write_file", "replace_file_content", "replace_content", "mkdir", "move_file"}
366 if tool_call.name in productive_tools:
367 return True
369 if tool_call.name == "run_shell_command":
370 # Check if it was a build/test command or a mutating command
371 cmd = tool_call.arguments.get("command", "").lower()
372 non_productive_prefixes = ("ls", "pwd", "cat", "find", "grep", "rg", "stat", "which")
373 if not any(cmd.startswith(p) for p in non_productive_prefixes):
374 return True
376 return False
378 def _detect_tool_loop(self, current_tool_name: str, current_args: dict) -> bool:
379 """Detect if the agent is stuck in a loop or repeating searches."""
380 if current_tool_name == "web_search":
381 query = current_args.get("query", "").lower()
382 if query in self.search_history:
383 return True
384 self.search_history.add(query)
385 return False
387 if not self.conversation:
388 return False
390 # Look at the last assistant message
391 last_msg = self.conversation[-1]
392 if last_msg.get("role") != "assistant":
393 return False
395 tool_calls = last_msg.get("tool_calls", [])
396 if not tool_calls:
397 return False
399 # Check the last tool call
400 last_call = tool_calls[-1]
401 last_func = last_call.get("function", {})
402 last_name = last_func.get("name")
403 last_args_str = last_func.get("arguments", "{}")
405 try:
406 last_args = json.loads(last_args_str)
407 except:
408 last_args = {}
410 # Strict equality check for now
411 if last_name == current_tool_name and last_args == current_args:
412 return True
414 return False
416 def _sanitize_history_for_summary(self, conversation: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
417 """
418 Strip tool calls from history but preserve context by converting them to text breadcrumbs.
419 This prevents API errors while allowing the model to 'see' its previous actions.
420 """
421 sanitized = []
422 for msg in conversation:
423 role = msg.get("role")
424 if role == "tool":
425 # Convert tool results to user-like 'Observation' messages?
426 # Actually, role='tool' is often what causes the error.
427 # Let's convert them to 'system' or 'user' notifications.
428 sanitized.append({
429 "role": "user",
430 "content": f"[Observation: Tool result received: {str(msg.get('content'))[:500]}]"
431 })
432 continue
434 new_msg = msg.copy()
435 content = new_msg.get("content") or ""
437 if "tool_calls" in new_msg:
438 # Convert structural tool_calls to text breadcrumbs
439 breadcrumbs = []
440 for tc in new_msg["tool_calls"]:
441 func = tc.get("function", {})
442 name = func.get("name", "unknown")
443 args = func.get("arguments", "{}")
444 breadcrumbs.append(f"[Action: {name} args={args}]")
446 # Append breadcrumbs to content
447 if breadcrumbs:
448 action_text = "\n".join(breadcrumbs)
449 content = f"{content}\n\n{action_text}".strip()
451 del new_msg["tool_calls"]
453 new_msg["content"] = content
455 # If content is still empty, skip it
456 if not new_msg["content"]:
457 continue
459 sanitized.append(new_msg)
460 return sanitized
462 async def _execute_diagnostic_probe(self) -> str:
463 """Execute a quick environment scan to help the agent find itself."""
464 try:
465 cwd = await self.tools_registry.execute("run_shell_command", {"command": "pwd"})
466 files = await self.tools_registry.execute("list_directory", {"path": "."})
467 return f"DIAGNOSTIC SYSTEM OBSERVATION:\n- Current Path: {cwd}\n- Available Files: {files}\n- Analysis: You are likely in the wrong directory. Use absolute paths or 'cd' carefully."
468 except:
469 return "DIAGNOSTIC FAILED."
471 def _select_optimal_persona(self, user_input: str) -> str:
472 """Map user intent to specialized Sovereign persona."""
473 text = user_input.lower()
474 if any(x in text for x in ["docker", "finch", "devops", "deploy", "setup", "server"]):
475 return "devops"
476 if any(x in text for x in ["refactor", "bug", "fix", "code", "python", "script", "app"]):
477 return "codex"
478 if any(x in text for x in ["research", "browse", "news", "learn", "latest"]):
479 return "research"
480 return "general"
482 def _get_persona_tools(self, persona_id: str) -> List[Dict[str, Any]]:
483 """Filter tools based on current cognitive mode."""
484 # 2025 Tier-2 Logic: Neural Tool Routing
485 # Sensory and General tools are ALWAYS included for basic orientation
486 mapping = {
487 "codex": ["motor", "cognitive"],
488 "devops": ["motor", "devops", "shell"],
489 "research": ["research", "swarm"],
490 "general": None # All tools
491 }
493 base_categories = ["sensory", "general"]
494 specialized = mapping.get(persona_id)
496 if specialized is None:
497 return self.tools_registry.get_openai_format_filtered(None)
499 combined = list(set(base_categories + specialized))
500 return self.tools_registry.get_openai_format_filtered(combined)
502 def _heal_tool_call(self, name: str) -> str:
503 """Sovereign Self-Healing: Fuzzy match malformed tool names."""
504 from difflib import get_close_matches
505 all_tools = [t.name for t in self.tools_registry.list_tools()]
507 # 1. Direct match
508 if name in all_tools: return name
510 # 2. Fuzzy match (2025 Tier-2 Resilience)
511 matches = get_close_matches(name, all_tools, n=1, cutoff=0.6)
512 if matches:
513 healed = matches[0]
514 # Internal log for transparency
515 return healed
517 return name # Return original if no match
519 def _parse_malformed_json(self, text: str) -> dict:
520 """Sovereign JSON Recovery: Best-effort fix for model formatting errors."""
521 if not text: return {}
522 try:
523 return json.loads(text)
524 except json.JSONDecodeError:
525 # 2025 Tier-2 Resilience: Regex-based fixing
526 fixed = text.strip()
527 # 1. Ensure it starts/ends with braces if it looks like an object
528 if not fixed.startswith("{"): fixed = "{" + fixed
529 if not fixed.endswith("}"): fixed = fixed + "}"
530 # 2. Fix unquoted keys (simple alphanumeric)
531 fixed = re.sub(r'(\w+):', r'"\1":', fixed)
532 # 3. Remove trailing commas before closing braces
533 fixed = re.sub(r',\s*([\]}])', r'\1', fixed)
535 try:
536 return json.loads(fixed)
537 except:
538 return {} # Return empty if still broken
540 def _get_tool_hint(self, name: str, error: str) -> str:
541 """Sovereign Hinting: Suggest tactical pivots for common errors."""
542 err = error.lower()
543 if "no such file" in err or "not found" in err:
544 return "TACTICAL HINT: Use 'ls -R' or 'generate_repo_map' to verify the exact path."
545 if "permission denied" in err:
546 return "TACTICAL HINT: Try a different path or check your authority level."
547 if "syntax" in err:
548 return "TACTICAL HINT: Use 'check_syntax' to diagnose the error before fixing."
549 return ""
551 async def send_message(
552 self,
553 user_input: str,
554 system_context: Optional[str] = None
555 ) -> AsyncIterator[Dict[str, Any]]:
556 """
557 Send a message and autonomously handle tool execution.
558 """
559 # Add user message
560 self.conversation.append({"role": "user", "content": user_input})
562 # Prepare messages with system context
563 from .system_prompts import get_system_prompt
564 persona_id = self._select_optimal_persona(user_input)
565 persona = get_system_prompt(persona_id)["prompt"]
567 research_mode = self.is_research_request(user_input)
568 research_context = self._research_instruction() if research_mode else None
570 # Inject Working Memory and Selected Persona
571 combined_context = f"{persona}\n\n{self._build_working_memory()}"
572 if research_context:
573 combined_context = f"{combined_context}\n\n{research_context}"
575 messages = self._prepare_messages(combined_context)
577 # Get available tools (Dynamically filtered by Persona)
578 tools = self._get_persona_tools(persona_id)
580 # Safety counter
581 base_limit = self.max_tool_calls_per_turn
582 current_limit = base_limit
583 total_tool_calls = 0
584 progress_score = 0
585 extensions_granted = 0
587 # Change state
588 self.state = StreamingState.RESPONDING
589 yield {"type": "state_change", "state": self.state}
591 tool_model_override = None
592 if tools and self.settings.provider == "openai":
593 tool_model_override = self._select_tool_model()
595 # Main autonomous loop
596 while True:
597 yield {"type": "cycle_start"}
598 yield {"type": "status", "message": "Analyzing context & preparing strategy..."}
600 # 1. Durable Checkpoint
601 if self.checkpoint_manager:
602 try:
603 self.checkpoint_manager.create_checkpoint(
604 description=f"Auto-cycle turn {total_tool_calls}",
605 messages=self.conversation,
606 metadata={"mana_used": total_tool_calls, "mana_limit": current_limit}
607 )
608 except: pass
610 # 2. Semantic Compression & Pruning
611 async for event in self._prune_raw_outputs():
612 yield event
614 # Neural Reset Guard
615 limit = self.settings.max_context_chars // 4
616 current_tok = self._estimate_tokens("\n".join([str(m.get("content","")) for m in self.conversation]))
617 if current_tok > (limit * 0.95) and extensions_granted >= 2:
618 async for event in self._pivot_context(user_input):
619 yield event
621 async for event in self._compress_history():
622 yield event
624 # Refresh context
625 self.plan_needs_revalidation = getattr(self, 'plan_needs_revalidation', False)
626 current_system = f"{combined_context}\n\n{self._build_working_memory()}"
627 messages = self._prepare_messages(current_system)
629 yield {
630 "type": "budget_update",
631 "limit": current_limit,
632 "used": total_tool_calls,
633 "progress": progress_score,
634 "extensions": extensions_granted,
635 "strategy": self._active_hypothesis
636 }
638 response_content = ""
639 tool_calls_data = {}
641 yield {"type": "status", "message": "Neural strategy formulated. Initiating stream..."}
643 try:
644 for attempt in range(2):
645 try:
646 stream = await self.client.create_completion(
647 model=tool_model_override or self.settings.default_model,
648 messages=messages,
649 temperature=self.settings.temperature,
650 stream=True,
651 tools=tools
652 )
654 yield {"type": "status", "message": "Neural stream active (Receiving data)..."}
656 start_stream = time.time()
657 chunk_count = 0
659 async for chunk in stream:
660 if not chunk.choices:
661 continue
663 chunk_count += 1
664 # Calculate TPS (rolling average)
665 elapsed = time.time() - start_stream
666 if elapsed > 0.1:
667 tps = chunk_count / elapsed
668 yield {"type": "tps_update", "tps": tps}
670 delta = chunk.choices[0].delta
672 # Handle reasoning content (thought tokens)
673 if hasattr(delta, "reasoning_content") and delta.reasoning_content:
674 thought = delta.reasoning_content
675 # 2025 Tier-3: Speculative Tool Prep
676 if any(x in thought.lower() for x in ["read", "scan", "search", "list"]):
677 yield {"type": "status", "message": "Speculative Prep: Warming up Sensory modules..."}
678 elif any(x in thought.lower() for x in ["run", "execute", "shell"]):
679 yield {"type": "status", "message": "Speculative Prep: Warming up Motor modules..."}
681 yield {"type": "thought", "text": thought}
683 # Handle text content
684 if delta.content:
685 text = delta.content
686 # 2025 Tier-2: Multi-modal Reflection Detection
687 # Trigger if XML tag OR common markdown header seen
688 lower_text = text.lower()
689 if any(x in lower_text for x in ["<reflection>", "[reflection]", "## reflection", "## neural audit", "## internal monologue"]):
690 yield {"type": "reflection_start"}
692 response_content += text
693 yield {"type": "content", "text": text}
695 # Handle tool calls
696 if delta.tool_calls:
697 for tc in delta.tool_calls:
698 idx = tc.index
699 if idx not in tool_calls_data:
700 tool_calls_data[idx] = {"id": tc.id or "", "name": "", "arguments": ""}
701 if tc.id: tool_calls_data[idx]["id"] = tc.id
703 # 2025 Tier-2 Guard: Null check for function
704 if hasattr(tc, "function") and tc.function:
705 if tc.function.name:
706 # HEAL: Self-correct malformed tool names
707 healed_name = self._heal_tool_call(tc.function.name)
708 tool_calls_data[idx]['name'] = healed_name
709 if tc.function.arguments: tool_calls_data[idx]["arguments"] += tc.function.arguments
710 break
711 except Exception as e:
712 # 2025 Hardening: Traceback Silencing & Clean UI Events
713 error_msg = str(e)
714 if "429" in error_msg or "rate limit" in error_msg.lower():
715 yield {"type": "error", "message": "Neural Connection Anomaly: Quota Exceeded (429). Suggestion: Wait a moment for Mana replenishment."}
716 elif "connection" in error_msg.lower() or "timeout" in error_msg.lower():
717 yield {"type": "error", "message": "Sensory Disconnect: API Connection Lost. Suggestion: Check network signal."}
718 else:
719 yield {"type": "error", "message": f"Cognitive Exception: {error_msg}"}
721 # 2025 Hardening: Plan Elasticity
722 self.plan_needs_revalidation = True
723 yield {"type": "engine_notification", "message": f"TOOL FAILURE: {tool_call.name}. Mission plan re-validation required."}
725 if attempt == 0:
726 yield {"type": "status", "message": "Retrying neural handshake..."}
727 continue
728 break # Stop turn on error after retries
730 if not tool_calls_data:
731 # 2025 Tier-2: Silent Stream Guard
732 if not response_content.strip() and tools:
733 yield {"type": "status", "message": "Neural stream silent. Retrying without tool manifest..."}
734 # Retry once without tools to force a text response
735 stream = await self.client.create_completion(
736 model=self.settings.default_model,
737 messages=messages,
738 temperature=self.settings.temperature,
739 stream=True,
740 tools=None
741 )
742 async for chunk in stream:
743 if chunk.choices and chunk.choices[0].delta.content:
744 text = chunk.choices[0].delta.content
745 response_content += text
746 yield {"type": "content", "text": text}
748 # 2025: Relentless Engineering Guard
749 # If any tool in history has a failing test result, we might need to continue
750 has_failing_tests = any("failed" in str(m.get("content", "")).lower() and m.get("role") == "tool" for m in self.conversation[-10:])
751 if has_failing_tests and "completed" in response_content.lower():
752 yield {"type": "engine_notification", "message": "ENGINEERING MANDATE: Test failures detected. Premature completion blocked."}
753 # Inject re-validation requirement
754 self.plan_needs_revalidation = True
756 self.conversation.append({"role": "assistant", "content": response_content})
757 self.state = StreamingState.IDLE
758 yield {"type": "state_change", "state": self.state}
759 break
761 if total_tool_calls + len(tool_calls_data) > current_limit:
762 if progress_score >= 2 and extensions_granted < 3:
763 extensions_granted += 1
764 current_limit += 15
765 progress_score = 0
766 yield {"type": "content", "text": "\n\n⚡ [i]AUTONOMY EXTENSION GRANTED[/i]\n"}
767 continue
768 break
770 # Execute tools
771 tool_results = []
772 for idx, tc_data in tool_calls_data.items():
773 # Parse arguments with Sovereign JSON Recovery
774 args = self._parse_malformed_json(tc_data['arguments'])
776 tool_call = ToolCall(id=tc_data['id'], name=tc_data['name'], arguments=args, status=ToolCallStatus.SCHEDULED)
777 yield {"type": "tool_call", "tool": tool_call}
779 if tool_call.name in self.risky_tools:
780 preview = self._build_tool_preview(tool_call)
781 yield {"type": "tool_preview", "tool": tool_call, "preview": preview, "requires_confirmation": not self.enable_auto_approval}
782 approved = True
783 if self.confirm_tool_callback and not self.enable_auto_approval:
784 approved = await self.confirm_tool_callback(tool_call, preview)
785 if not approved:
786 tool_call.status = ToolCallStatus.CANCELLED
787 yield {"type": "tool_result", "tool": tool_call}
788 tool_results.append(tool_call)
789 continue
791 # Execute the tool
792 tool_call.status = ToolCallStatus.EXECUTING
793 yield {"type": "tool_executing", "tool": tool_call}
795 try:
796 result = await self.tools_registry.execute(tool_call.name, tool_call.arguments)
797 normalized = normalize_tool_result(tool_call.name, result)
798 tool_call.structured_result = normalized
799 tool_call.result = normalized.get("message") or normalized.get("stdout") or normalized.get("stderr") or str(result)
800 tool_call.status = map_normalized_status(normalized)
802 if tool_call.status == ToolCallStatus.ERROR:
803 # 2025 Hardening: Plan Elasticity
804 self.plan_needs_revalidation = True
805 yield {"type": "engine_notification", "message": f"CRITICAL: {tool_call.name} failed. Tactical re-validation engaged."}
807 key = f"{tool_call.name}:{json.dumps(tool_call.arguments, sort_keys=True)}"
808 self.tool_failure_history[key] = self.tool_failure_history.get(key, 0) + 1
809 # Sovereign Hinting: Help the agent pivot
810 hint = self._get_tool_hint(tool_call.name, tool_call.result or "")
811 if hint:
812 tool_call.result = f"{tool_call.result}\n\n{hint}"
814 total_tool_calls += 1
815 if self._is_productive_action(tool_call): progress_score += 1
817 yield {"type": "budget_update", "limit": current_limit, "used": total_tool_calls, "progress": progress_score, "extensions": extensions_granted}
818 except Exception as e:
819 tool_call.error = str(e); tool_call.status = ToolCallStatus.ERROR
821 yield {"type": "tool_result", "tool": tool_call}
822 tool_results.append(tool_call)
824 self.conversation.append({"role": "assistant", "content": response_content or None, "tool_calls": [{"id": tc.id, "type": "function", "function": {"name": tc.name, "arguments": json.dumps(tc.arguments)}} for tc in tool_results]})
825 for tc in tool_results:
826 self.conversation.append({"role": "tool", "tool_call_id": tc.id, "content": tc.result or tc.error or "No result"})
828 except Exception as e:
829 yield {"type": "error", "message": str(e)}
830 break
832 except Exception as e:
833 yield {"type": "error", "message": str(e)}
834 self.state = StreamingState.IDLE
835 yield {"type": "state_change", "state": self.state}
836 break
838 def _get_image_content_block(self, file_path: str) -> Dict[str, Any]:
839 """Convert image to LLM-compatible content block."""
840 import base64
841 import mimetypes
842 try:
843 path = Path(file_path)
844 mime_type, _ = mimetypes.guess_type(file_path)
845 if not mime_type: mime_type = "image/jpeg"
847 with open(path, "rb") as image_file:
848 base64_image = base64.b64encode(image_file.read()).decode('utf-8')
850 return {
851 "type": "image_url",
852 "image_url": {
853 "url": f"data:{mime_type};base64,{base64_image}"
854 }
855 }
856 except Exception as e:
857 return {"type": "text", "text": f"[Error loading image {file_path}: {e}]"}
859 def _redact_sensitive_info(self, text: str) -> str:
860 """Sovereign Shield: Prevent credential leakage to model providers."""
861 patterns = [
862 (r'(?:api_key|secret|password|passwd|token|auth)\s*[:=]\s*["\']([a-zA-Z0-9_\-\.]{10,})["\']', "[REDACTED_SECRET]"),
863 (r'xox[bp]-[a-zA-Z0-9\-]+', "[REDACTED_SLACK_TOKEN]"),
864 (r'-----BEGIN (?:RSA|OPENSSH|PRIVATE) KEY-----[\s\S]+?-----END (?:RSA|OPENSSH|PRIVATE) KEY-----', "[REDACTED_PRIVATE_KEY]"),
865 (r'AIza[0-9A-Za-z\\-_]{35}', "[REDACTED_GGL_KEY]"),
866 (r'sk-[a-zA-Z0-9]{48}', "[REDACTED_OAI_KEY]")
867 ]
868 redacted = text
869 for pattern, replacement in patterns:
870 redacted = re.sub(pattern, replacement, redacted, flags=re.IGNORECASE)
871 return redacted
873 def _prepare_messages(self, system_context: Optional[str] = None) -> List[Dict[str, Any]]:
874 """Prepare multimodal messages with strict context budgeting and Ground Truth anchor."""
875 from .context import context as context_manager
877 # 2025 Tier-2: Vision Prep (Define early to prevent NameError)
878 image_blocks = [self._get_image_content_block(p) for p in context_manager.image_files]
880 # GROUND TRUTH ANCHOR (2025 Best Practice)
881 # Injected into EVERY turn to prevent environment hallucination
882 tools_list = ", ".join([t.name for t in self.tools_registry.list_tools()])
884 # 2025 Tier-3: Immutable Mission Anchor
885 # We find the very first user message to preserve the core goal
886 first_prompt = next((m["content"] for m in self.conversation if m["role"] == "user"), "Initial Orientation")
888 ground_truth = (
889 f"### MISSION ANCHOR (IMMUTABLE)\n"
890 f"- ORIGINAL OBJECTIVE: {first_prompt[:200]}\n"
891 f"- ACTIVE STRATEGY: {self._active_hypothesis or 'Grounding environment'}\n\n"
892 f"### SYSTEM STATE ANCHOR\n"
893 f"- CURRENT DATE: {datetime.now().strftime('%A, %b %d, %Y')}\n"
894 f"- OPERATING SYSTEM: {sys.platform}\n"
895 f"- SHELL: {os.getenv('SHELL', 'unknown')}\n"
896 f"- AVAILABLE TOOLS: {tools_list}\n"
897 f"- CWD: {Path.cwd()}\n"
898 )
900 limit = max(int(getattr(self.settings, "max_context_chars", 0) or 0), 0)
901 context_text = f"{ground_truth}\n\n{system_context or ''}"
903 # Redact context
904 context_text = self._redact_sensitive_info(context_text)
906 if limit > 0 and len(context_text) > limit:
907 context_text = self._truncate_string(context_text, limit)
909 remaining_budget = max(limit - len(context_text), 0) if limit > 0 else None
910 if limit > 0:
911 conversation = self._trim_conversation(self.conversation, remaining_budget or 0)
912 else:
913 conversation = [copy.deepcopy(msg) for msg in self.conversation]
915 messages: List[Dict[str, Any]] = []
916 if context_text:
917 # 2025 Hardening: Plan Elasticity Injection
918 if getattr(self, 'plan_needs_revalidation', False):
919 context_text += "\n\n### CRITICAL: TACTICAL RE-VALIDATION REQUIRED\nYour previous plan or tool execution encountered a failure. You MUST pause, re-evaluate the project state, and output a revised 'PLAN' block before taking any further Motor actions."
920 # Reset flag after injection
921 self.plan_needs_revalidation = False
923 messages.append({"role": "system", "content": context_text})
925 # 2025 Tier-2: STRICT Schema Guard
926 # Mistral/OpenRouter fail if 'tool' follows 'system'
927 # We ensure every 'tool' message follows an 'assistant' message with tool_calls
928 last_role = "system"
930 for i, msg in enumerate(conversation):
931 current_role = msg.get("role")
933 # Error Prevention: tool cannot follow system
934 if current_role == "tool" and last_role == "system":
935 # Inject a dummy transition
936 messages.append({"role": "user", "content": "[Neural Handshake Initialized]"})
937 messages.append({"role": "assistant", "content": "Acknowledged. Resuming autonomous cycle..."})
938 last_role = "assistant"
940 # Redact and append
941 content = msg.get("content")
942 if isinstance(content, str):
943 msg["content"] = self._redact_sensitive_info(content)
945 if msg["role"] == "user" and i == len(conversation) - 1 and image_blocks:
946 content = [{"type": "text", "text": msg["content"]}] + image_blocks
947 messages.append({"role": "user", "content": content})
948 else:
949 messages.append(msg)
951 last_role = msg.get("role")
953 return messages
955 def _content_length(self, content: Any) -> int:
956 if content is None: return 0
957 if isinstance(content, str): return self.tokenizer.count(content)
958 if isinstance(content, list):
959 total = 0
960 for block in content:
961 text = block.get("text") if isinstance(block, dict) else str(block)
962 total += self.tokenizer.count(text or "")
963 return total
964 return self.tokenizer.count(str(content))
966 def _truncate_string(self, text: str, limit: int) -> str:
967 if not text or limit <= 0:
968 return ""
969 ellipsis = "…"
970 if len(text) <= limit:
971 return text
972 available = max(limit - len(ellipsis), 0)
973 suffix = text[-available:] if available > 0 else ""
974 return f"{ellipsis}{suffix}"
976 def _truncate_blocks(self, blocks: List[Any], limit: int) -> Optional[List[Dict[str, Any]]]:
977 if limit <= 0:
978 return None
979 truncated: List[Dict[str, Any]] = []
980 consumed = 0
981 for block in reversed(blocks):
982 text = block.get("text") if isinstance(block, dict) else str(block)
983 if not text:
984 continue
985 chunk_len = len(text)
986 if consumed + chunk_len <= limit:
987 truncated.insert(0, block if isinstance(block, dict) else {"text": text})
988 consumed += chunk_len
989 if consumed >= limit:
990 break
991 continue
993 remaining = max(limit - consumed, 0)
994 if remaining <= 0:
995 break
996 truncated_text = self._truncate_string(text, remaining)
997 if not truncated_text:
998 continue
999 if isinstance(block, dict):
1000 new_block = {**block, "text": truncated_text}
1001 else:
1002 new_block = {"text": truncated_text}
1003 truncated.insert(0, new_block)
1004 consumed += len(truncated_text)
1005 break
1007 return truncated if consumed > 0 else None
1009 def _truncate_content(self, content: Any, limit: int) -> Optional[Any]:
1010 if limit <= 0:
1011 return None
1012 if isinstance(content, str):
1013 return self._truncate_string(content, limit)
1014 if isinstance(content, list):
1015 truncated = self._truncate_blocks(content, limit)
1016 return truncated
1017 return self._truncate_string(str(content), limit)
1019 def _fit_message_within_budget(self, message: Dict[str, Any], budget: int) -> Tuple[Optional[Dict[str, Any]], int]:
1020 if budget <= 0:
1021 return None, 0
1022 content = message.get("content")
1023 length = self._content_length(content)
1024 if length <= budget:
1025 return copy.deepcopy(message), length
1026 truncated = self._truncate_content(content, budget)
1027 if truncated is None:
1028 return None, 0
1029 fitted = copy.deepcopy(message)
1030 fitted["content"] = truncated
1031 return fitted, self._content_length(truncated)
1033 def _trim_conversation(self, conversation: List[Dict[str, Any]], budget: int) -> List[Dict[str, Any]]:
1034 if budget <= 0:
1035 return []
1037 trimmed = deque()
1038 total = 0
1040 def drop_oldest_pair():
1041 nonlocal total
1042 if not trimmed:
1043 return
1044 removed = trimmed.popleft()
1045 total -= self._content_length(removed.get("content"))
1046 if removed.get("role") == "assistant" and removed.get("tool_calls"):
1047 while trimmed and trimmed[0].get("role") == "tool":
1048 tool_removed = trimmed.popleft()
1049 total -= self._content_length(tool_removed.get("content"))
1051 for msg in conversation:
1052 fitted, cost = self._fit_message_within_budget(msg, budget)
1053 if not fitted:
1054 continue
1055 trimmed.append(fitted)
1056 total += cost
1058 while trimmed and total > budget:
1059 drop_oldest_pair()
1061 return list(trimmed)
1063 def reset(self):
1064 """Reset conversation state"""
1065 self.conversation = []
1066 self.pending_tool_calls = []
1067 self.state = StreamingState.IDLE
1069 def get_conversation(self) -> List[Dict[str, Any]]:
1070 """Get current conversation history"""
1071 return self.conversation.copy()