Spaces:
Running
Running
| import os | |
| import json | |
| import logging | |
| import sys | |
| import warnings | |
| import time | |
| import requests | |
| import re | |
| import base64 | |
| import subprocess | |
| import hashlib | |
| import hmac | |
| import uuid | |
| import secrets | |
| import threading | |
| import traceback | |
| from dotenv import load_dotenv | |
| try: | |
| from google import genai | |
| from google.genai import types | |
| except ImportError: | |
| genai = None | |
| from typing import Generator, Optional, List, Dict, Any | |
| from .browser_controller import BrowserController | |
| from .coding_tools import CodingToolkit, OPENAI_SCHEMAS as CODING_TOOL_SCHEMAS, CODING_AGENT_GUIDE | |
| from .tool_runtime import ToolRuntime, RuntimeCtx | |
| import sovereign_memory | |
| import quota_manager | |
| from quota_manager import ImperialQuotaManager, QuotaExceededError | |
| # ๐๏ธ [Imperial Obsidian Directive] ์ต์๋์ธ ์๋ ๊ฐ์ง ์ ์์คํ ์ง์์ ์ฃผ์ . | |
| # ์ฝํ ๋ชจ๋ธ์ด "๊ณง ๋ง๋ค๊ฒ ์ต๋๋ค"๋ผ๊ณ ๋ง๋ง ํ๊ณ obsidian_write๋ฅผ ํธ์ถํ์ง ์๋ ๋ฌธ์ ๋ฅผ | |
| # ๊ฐํ ๋ช ๋ นํ ์ง์๋ก ์ฐจ๋จํ๋ค. (ask_user/coding ๊ฐ์ด๋์ ๋์ผ ํจํด) | |
| _OBSIDIAN_DIRECTIVE = ( | |
| "\n\n## ๐๏ธ Imperial Obsidian Directive (๋ฐ๋์ ์ค์)\n" | |
| "์ฌ์ฉ์๊ฐ ์ต์๋์ธ์ ๋ ธํธ/์ํค/๋ฌธ์/ํ์ด์ง๋ฅผ ๋ง๋ค๊ฑฐ๋(๋ง๋ค์ด/์์ฑ/์ ๋ฆฌ/์ ์ฅ/๊ธฐ๋ก) ์ฐพ์๋ฌ๋ผ๊ณ ํ๋ค.\n" | |
| "1. **๋ง๋ก ์ฝ์ํ์ง ๋ง๋ผ.** '๊ณง ๋ง๋ค๊ฒ ์ต๋๋ค/์์ฑํ๊ฒ ์ต๋๋ค/์์ฑํ๊ฒ ์ต๋๋ค'๋ผ๊ณ ๋ง ๋ตํ๊ณ ๋๋ด๋ ๊ฒ์ " | |
| "์ ๋ ๊ธ์ง โ ์๋ฌด๊ฒ๋ ๋ง๋ค์ด์ง์ง ์๋ ์คํจ๋ค. ๋ฐ๋์ **๊ฐ์ ํด์ ๋๊ตฌ๋ฅผ ์ค์ ๋ก ํธ์ถ**ํ๋ผ.\n" | |
| "2. ์์ฑ ์์ฒญ์ด๋ฉด `obsidian_write(path, content, mode)`๋ฅผ ํธ์ถํ๋ผ. content๋ ๋ค๊ฐ ์ง์ ์ ์ฒด " | |
| "๋งํฌ๋ค์ด(# ์ ๋ชฉ, [[๋ฐฑ๋งํฌ]], #ํ๊ทธ, - ๋ชฉ๋ก)์ ์จ์ ์ฑ์ด๋ค. ํ๋ ์ด์คํ๋ ๊ธ์ง.\n" | |
| "3. ์ฌ๋ฌ ํ์ด์ง ์ํค๋ฉด `obsidian_write`๋ฅผ **์ฌ๋ฌ ๋ฒ** ํธ์ถํ๋ผ(๋ ธํธ 1๊ฐ๋น 1ํ). ์ธ๋ฑ์ค ๋ ธํธ๋ฅผ ๋ง๋ค๊ณ " | |
| "๊ฐ ๋ ธํธ๋ฅผ `[[ ]]`๋ก ์ฐ๊ฒฐํ๋ผ. ์: 'Wiki/ํ๋ฏผ๊ฒ์์ฆ/index.md' + ๊ฒ์๋ณ ๋ ธํธ๋ค.\n" | |
| "4. ๊ธฐ์กด ๋ ธํธ๊ฐ ์๋์ง ๋ชจ๋ฅด๋ฉด ๋จผ์ `obsidian_search`๋ก ํ์ธํ ๋ค, ์์ผ๋ฉด mode='overwrite'/'append'.\n" | |
| "5. ๋๊ตฌ ํธ์ถ ํ์๋ง '์์ฑ ์๋ฃ'๋ผ๊ณ ๋ณด๊ณ ํ๋ผ. ํธ์ถ ์ ์๋ ์๋ฃ๋ผ๊ณ ๋งํ์ง ๋ง๋ผ.\n" | |
| ) | |
| # ๐ฑ [Imperial v4.0] MCP Client dynamic import | |
| try: | |
| from mcp_client import get_mcp_client | |
| except ImportError: | |
| import sys | |
| import os | |
| sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| try: | |
| from mcp_client import get_mcp_client | |
| except ImportError: | |
| get_mcp_client = None | |
| # Configure logging | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger("JarvisBrain") | |
| # โโโโโโโโโโโโโโโโ ๐ Ask User (ํด ์ค๊ฐ ์ ํ์ง ์ง๋ฌธ) โโโโโโโโโโโโโโโโ | |
| # ๋ชจ๋ธ์ด ์์ ๋์ค ์ ์ ๊ฒฐ์ ์ ๊ธฐ๋ค๋ฆฌ๋ ์๋น์ค(AskUserQuestion)์ ๋ฉ์ปค๋์ฆ. | |
| # ์คํธ๋ฆผ์ ```imperial-ask``` ๋ธ๋ก์ ๋ฐฉ์ถ(ํค์๋ฌ์ด ๋ฒํผ+ํ์ด๋จธ ๋ ๋๋ง)ํ๊ณ , | |
| # threading.Event๋ก ๋๊ธฐ โ POST /api/shadow/ask/answer ๊ฐ ํด์ . | |
| # ํ์์์ ์ ์๋ ์ทจ์ + ์ฌ์ ๊ณ ์ง + ๋ ์ง์คํธ๋ฆฌ ์ ๋ฆฌ (์ข๋น ์ค๋ ๋ ๋ฐฉ์ง). | |
| _PENDING_ASKS = {} | |
| _PENDING_ASKS_LOCK = threading.Lock() | |
| def submit_ask_answer(ask_id: str, answer: str) -> bool: | |
| """ํค์๋ฌ ๋ฒํผ ํญ์ด ํธ์ถ โ ๋๊ธฐ ์ค์ธ ask๋ฅผ ๊นจ์ด๋ค. ์์ผ๋ฉด False (๋ง๋ฃ/์ทจ์๋จ).""" | |
| with _PENDING_ASKS_LOCK: | |
| entry = _PENDING_ASKS.get(ask_id) | |
| if not entry: | |
| return False | |
| entry["answer"] = str(answer or "").strip() | |
| entry["event"].set() | |
| return True | |
| def begin_ask(args: dict) -> dict: | |
| """ask ์์ โ ๋๊ธฐ ์ํธ๋ฆฌ ๋ฑ๋ก ํ UI ํ์ด๋ก๋ ๋ฐํ. (MCP/์ธ๋ถ ์์ด์ ํธ ๊ฒฝ๋ก ๊ณต์ฉ)""" | |
| import uuid as _uuid | |
| ask_id = _uuid.uuid4().hex[:12] | |
| try: | |
| timeout = int(args.get("timeout_seconds", 120) or 120) | |
| except (TypeError, ValueError): | |
| timeout = 120 | |
| timeout = max(30, min(timeout, 600)) | |
| options = [str(o) for o in (args.get("options") or []) if str(o).strip()][:6] | |
| payload = { | |
| "ask_id": ask_id, | |
| "question": str(args.get("question", "")).strip(), | |
| "options": options, | |
| "timeout_seconds": timeout, | |
| "expires_at": time.time() + timeout, # ํค์๋ฌ ์นด์ดํธ๋ค์ด ํ์ด๋จธ์ฉ (epoch) | |
| } | |
| with _PENDING_ASKS_LOCK: | |
| _PENDING_ASKS[ask_id] = { | |
| "event": threading.Event(), "answer": None, | |
| "created": time.time(), "payload": payload, # pending ๋ชฉ๋ก ๋ ธ์ถ์ฉ | |
| } | |
| return payload | |
| def wait_ask(ask_id: str, timeout: int): | |
| """๋ต๋ณ ๋๊ธฐ. ๋ต์ด ์ค๋ฉด ๋ฌธ์์ด, ํ์์์์ด๋ฉด None. ์ด๋ ์ชฝ์ด๋ ๋ ์ง์คํธ๋ฆฌ๋ ๋ฐ๋์ ์ ๋ฆฌ.""" | |
| with _PENDING_ASKS_LOCK: | |
| entry = _PENDING_ASKS.get(ask_id) | |
| if not entry: | |
| return None | |
| try: | |
| answered = entry["event"].wait(timeout) | |
| return entry["answer"] if answered else None | |
| finally: | |
| # ๐งน ์ข๋น ๋ฐฉ์ง: ์ฑ๊ณต/ํ์์์/์์ธ ๋ฌด๊ด ํญ์ ์ ๊ฑฐ โ ๋ฆ๊ฒ ์จ ๋ต์ submit์ด False ๋ฐํ | |
| with _PENDING_ASKS_LOCK: | |
| _PENDING_ASKS.pop(ask_id, None) | |
| def list_pending_asks() -> list: | |
| """ํ์ฌ ๋๊ธฐ ์ค(๋ฏธ์๋ต)์ธ ask ํ์ด๋ก๋ ๋ชฉ๋ก โ ํค์๋ฌ out-of-band ์นด๋ ํ์์ฉ.""" | |
| now = time.time() | |
| with _PENDING_ASKS_LOCK: | |
| return [ | |
| e["payload"] for e in _PENDING_ASKS.values() | |
| if e.get("payload") and not e["event"].is_set() | |
| and e["payload"].get("expires_at", 0) > now | |
| ] | |
| # โโโโโโโโโโโโโโโโ ๐ Debug Mode (๋๊ตฌ ๋ก๊ทธ ๋ ธ์ถ ์ ์ด) โโโโโโโโโโโโโโโโ | |
| # ํค์๋ฌ '๊ทธ๋ฆผ์ ๋๋ ์ค์ '์ debugMode ํ ๊ธ. ON์ด๋ฉด โก ๋๊ตฌ ์งํ ๋ก๊ทธ๋ฅผ ์ฑํ | |
| # ์คํธ๋ฆผ์ ๋ฐฉ์ถ(=DB ์ ์ฅ/๋ฒกํฐํ ํฌํจ), OFF๋ฉด ์๋ฒ ๋ก๊ทธ์๋ง ๋จ๊ธด๋ค. | |
| # ๋๊ตฌ ์คํ ์์ฒด๋ ์ค์ ๊ณผ ๋ฌด๊ดํ๊ฒ ํญ์ ๋์ํ๋ค. | |
| _DEBUG_MODE_CACHE = {"value": False, "checked_at": 0.0} | |
| def _is_debug_mode() -> bool: | |
| """JARVIS_SHADOW_SETTINGS.json์ debugMode๋ฅผ ํซ๋ฆฌ๋ก๋(5์ด ์บ์)๋ก ์ฝ๋๋ค.""" | |
| now = time.time() | |
| if now - _DEBUG_MODE_CACHE["checked_at"] < 5: | |
| return _DEBUG_MODE_CACHE["value"] | |
| value = False | |
| try: | |
| proj_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| settings_file = os.path.join(proj_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_SHADOW_SETTINGS.json") | |
| if os.path.exists(settings_file): | |
| with open(settings_file, 'r', encoding='utf-8') as f: | |
| value = bool(json.load(f).get("debugMode", False)) | |
| except Exception: | |
| pass | |
| _DEBUG_MODE_CACHE["value"] = value | |
| _DEBUG_MODE_CACHE["checked_at"] = now | |
| return value | |
| # โโโโโโโโโโโโโโโโ ๐ง Reasoning ๋ ธ์ถ ์ ์ด โโโโโโโโโโโโโโโโ | |
| # ์ผ๋ถ ๋ชจ๋ธ(์: minimax-m2.7)์ ์ฌ๊ณ ๊ณผ์ ์ reasoning_content ์ฑ๋๋ก ์์๋ด ์ต์ข | |
| # ๋ต๋ณ์ ๋ฉํ/์ง์ ๋ฉ์๋ฆฌ๊ฐ ์์ธ๋ค. show_reasoning(๊ธฐ๋ณธ False)์ด๋ฉด ์ถ๋ก ์ฑ๋์ | |
| # ์คํธ๋ฆผยท์ต์ข ๋ต๋ณ์์ ์จ๊ธด๋ค. (ํค์๋ฌ '๊ทธ๋ฆผ์ ๋๋ ์ค์ '์ show_reasoning ํ ๊ธ) | |
| _SHOW_REASONING_CACHE = {"value": False, "checked_at": 0.0} | |
| def _show_reasoning() -> bool: | |
| """JARVIS_SHADOW_SETTINGS.json์ show_reasoning์ ํซ๋ฆฌ๋ก๋(5์ด ์บ์)๋ก ์ฝ๋๋ค.""" | |
| now = time.time() | |
| if now - _SHOW_REASONING_CACHE["checked_at"] < 5: | |
| return _SHOW_REASONING_CACHE["value"] | |
| value = False | |
| try: | |
| proj_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| settings_file = os.path.join(proj_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_SHADOW_SETTINGS.json") | |
| if os.path.exists(settings_file): | |
| with open(settings_file, 'r', encoding='utf-8') as f: | |
| value = bool(json.load(f).get("show_reasoning", False)) | |
| except Exception: | |
| pass | |
| _SHOW_REASONING_CACHE["value"] = value | |
| _SHOW_REASONING_CACHE["checked_at"] = now | |
| return value | |
| def _pick_nonreasoning_final(content_text, reasoning_text): | |
| """์ถ๋ก ์จ๊น ๋ชจ๋์์ content๊ฐ ๋น๋ฉด reasoning์ผ๋ก ํด๋ฐฑํด ๋ต๋ณ ์ ์ค์ ๋ง๋๋ค. | |
| ๋๋ถ๋ถ์ ๋ชจ๋ธ์ ์ค์ ๋ต๋ณ์ content๋ก ๋ธ๋ค โ content ์ฐ์ . content๊ฐ ์์ ํ | |
| ๋น์ด ์์ ๋๋ง(์ถ๋ก ์ฑ๋์๋ง ๋ต์ ๋ด๋ ๋ชจ๋ธ ๋๋น) reasoning์ ํด๋ฐฑ์ผ๋ก ์ด๋ค. | |
| """ | |
| if (content_text or "").strip(): | |
| return content_text | |
| return reasoning_text or "" | |
| def _summarize_tool_result(display_name: str, func_name: str, result) -> str: | |
| """๐ฑ [๊ฐ๋ ์ฑ] ๋๊ตฌ ์คํ ๊ฒฐ๊ณผ๋ฅผ ์ฑํ ์ ๋ณด์ฌ์ค ํ ์ค ์์ฝ์ผ๋ก ๋ณํํ๋ค. | |
| (์์ JSON 200์ ๋คํ๊ฐ ๊ทธ๋๋ก ๋ ธ์ถ๋๋ ๋ฌธ์ ๊ฐ์ )""" | |
| text = str(result or '').strip() | |
| # ์คํจ๋ ๋ช ํํ๊ฒ | |
| if text.startswith('โ') or text.lower().startswith('error'): | |
| one = ' '.join(text.split())[:160] | |
| return f"**{func_name}** ์คํจ โ {one}" | |
| # JSON ๊ฒฐ๊ณผ๋ฉด ํต์ฌ๋ง ์ถ์ถ | |
| summary = None | |
| if text.startswith('{') or text.startswith('['): | |
| try: | |
| data = json.loads(text) | |
| if isinstance(data, dict): | |
| results = data.get('results') | |
| if isinstance(results, list): | |
| titles = [str((r or {}).get('title', ''))[:40] for r in results[:3] if isinstance(r, dict)] | |
| summary = f"๊ฒ์ ๊ฒฐ๊ณผ {len(results)}๊ฑด" + (f" โ {' / '.join(t for t in titles if t)}" if titles else "") | |
| elif 'content' in data: | |
| summary = f"๋ณธ๋ฌธ {len(str(data['content']))}์ ์์ " | |
| except Exception: | |
| pass | |
| if summary is None: | |
| one = ' '.join(text.split()) | |
| summary = (one[:120] + 'โฆ') if len(one) > 120 else one | |
| return f"**{func_name}** ์๋ฃ โ {summary}" | |
| class JarvisBrain: | |
| # ๐ฑ [Imperial Fix] google-genai 2.10+ ์ generate_content๋ config.model_copy(deep=True)๋ก | |
| # ์ค์ ์ deep-copy ํ๋ค. self._tools์๋ ๋ฐ์ด๋ ๋ฉ์๋(self.xxx)๊ฐ ๋ค์ด๊ฐ๋๋ฐ, ๊ทธ deepcopy๊ฐ | |
| # __self__(์ด ๋ธ๋ ์ธ ์ธ์คํด์ค = genai.Client httpx ๋ฝ + threading.local ๋ณด์ )๊น์ง ๋ณต์ฌํ๋ ค๋ค | |
| # 'cannot pickle _thread.lock' ์ผ๋ก ์คํจํ๋ค(ํนํ Gemini AFC ๋น์คํธ๋ฆฌ๋ฐ ๊ฒฝ๋ก). ๋ธ๋ ์ธ์ | |
| # deepcopy ๋ถ๋ณ(์๊ธฐ ์์ ๋ฐํ)์ผ๋ก ๋ง๋ค์ด ๋ฝ ๋ณต์ฌ๋ฅผ ํํผํ๋ค โ SDK๋ ๋ธ๋ ์ธ์ ๋ณํํ์ง ์์ผ๋ฏ๋ก ์์ . | |
| def __deepcopy__(self, memo): | |
| memo[id(self)] = self | |
| return self | |
| def __init__(self): | |
| # ๐ฑ [Imperial] Force drop legacy environment variables to silence SDK warnings | |
| os.environ.pop("GOOGLE_API_KEY", None) | |
| self.api_key = os.environ.get("GEMINI_API_KEY") | |
| # Guard: Check if the key is a Bitwarden placeholder (Aegis format) | |
| if self.api_key and (self.api_key.startswith("BW_ID:") or self.api_key.startswith("Imperial:")): | |
| logger.info(f"Targeting Key ID: {self.api_key}") | |
| # [๐ฑ Imperial Fix] If it's a placeholder, force missing state to trigger recovery | |
| self.api_key = None | |
| # [IMPERIAL FIX] If API Key is still missing or was a placeholder, try to load from Bitwarden | |
| # [๐ฑ Imperial Centralized Secret Recovery] | |
| if not self.api_key: | |
| self.api_key = sovereign_memory.get_secret("GEMINI_API_KEY") | |
| if self.api_key: | |
| os.environ["GEMINI_API_KEY"] = self.api_key | |
| logger.info("[SUCCESS] GEMINI_API_KEY recovered from Aegis Oracle.") | |
| else: | |
| logger.warning("[SECURITY] Failed to recover GEMINI_API_KEY from Aegis Oracle.") | |
| self.brave_api_key = os.environ.get("BRAVE_API_KEY") | |
| if self.brave_api_key and (self.brave_api_key.startswith("BW_ID:") or self.brave_api_key.startswith("Imperial:")): | |
| self.brave_api_key = None | |
| if not self.brave_api_key: | |
| self.brave_api_key = sovereign_memory.get_secret("BRAVE_API_KEY") | |
| if self.brave_api_key: | |
| os.environ["BRAVE_API_KEY"] = self.brave_api_key | |
| logger.info("[SUCCESS] BRAVE_API_KEY recovered from Aegis Oracle.") | |
| if self.api_key and genai: | |
| # Mask key for safety in logs | |
| masked = self.api_key[:8] + "..." + self.api_key[-4:] | |
| logger.info(f"[STATUS] Imperial Brain Synchronized with key: {masked}") | |
| self.client = genai.Client(api_key=self.api_key) | |
| else: | |
| logger.error("Imperial Brain Initialization Failed: GEMINI_API_KEY is missing or invalid.") | |
| self.client = None | |
| if self.brave_api_key: | |
| masked_brave = self.brave_api_key[:8] + "..." + self.brave_api_key[-4:] if len(self.brave_api_key) > 8 else "..." | |
| logger.info(f"[STATUS] Imperial Brave Search Synchronized with key: {masked_brave}") | |
| self.current_sender = "๋ง์๋" # [๐ฑ Identity SSOT] | |
| # ์๋ ๊ฒ์์ Aegis common_retrieval_policy๊ฐ ๋ช ์์ ์ผ๋ก ํ์ฉํ ๋๋ง ๋์ํ๋ค. | |
| self.web_search_enabled = False | |
| # Initialize Hands (Tools) | |
| self.browser = BrowserController() | |
| # ๐ ๏ธ [Imperial Coding Arsenal] Claude Code์ ์์ด์ ํฑ ์ฝ๋ฉ ๋๊ตฌ | |
| self.coding = CodingToolkit() | |
| # ๐ก๏ธ [Tool Runtime] 3๊ฐ OpenAI์ ๋ํ ๊ฒฝ๋ก๊ฐ ๊ณต์ ํ๋ ๋จ์ผ ๋๊ตฌ ์คํ ๋ฐํ์ | |
| self.runtime = ToolRuntime(self, _is_debug_mode, _summarize_tool_result, | |
| get_mcp_client=get_mcp_client) | |
| # [๐ง ์ ์ง ๊ฒ์ดํธ] ์์ฒญ๋ณ ์ปจํ ์คํธ(aegis_url/token/session) โ ๋๊ตฌ๊ฐ ํธ์ถ ์์ ์ ์ฐธ์กฐ | |
| self._request_ctx = threading.local() | |
| self._tools = [ | |
| # ์ฝ๋ฉ ๋๊ตฌ๋ฅผ ์ต์ฐ์ ๋ฐฐ์น โ ์ฝ๋ ์ง๋ฌธ์์ ๋ชจ๋ธ์ด ๋จผ์ ๊ณ ๋ คํ๊ฒ ํจ | |
| *self.coding.callables(), | |
| # [๐ง ์ ์ง ๊ฒ์ดํธ] ๊ธฐ์ต/๊ธฐ๋ก ์ฌ์ค ์กฐํ ๋๊ตฌ๋ฅผ ์ต์๋จ ๋ฐฐ์น โ ๊ธฐ์ต ์ง๋ฌธ์ ์ถ์ธก ๋์ ์กฐํ | |
| self.recall_memory, | |
| self.count_history, | |
| # [๐ง ์ง์ ๋ฒ ์ด์ค] ์ด์ ์ฌ์ค ์กฐํ/๊ธฐ๋ก โ ์์คํ ๊ตฌ์ฑ ์ง๋ฌธ์ ์ถ์ธก ๋์ ์กฐํ | |
| self.search_knowledge, | |
| self.save_knowledge, | |
| self.web_search, # ๐ ์ค์๊ฐ ์น ๊ฒ์ ๋๊ตฌ (๋ค์ด๋ฒ/๋ค์/๊ตฌ๊ธ headless Playwright) | |
| # [๐ผ๏ธ ๋ฏธ๋์ด] ๋ก์ดํฐ-ํ๋ฆฌ ์ฌ์ง/์์/GIF ๊ฒ์ โ ์ด๋ฏธ์ง ์์ฒญ์ ์ฌ์ดํธ ์ถ์ฒ ๋์ ์ค์ ๊ฒ์ | |
| self.search_media, | |
| # [๐ง 3D ์์ ] ๋ก์ดํฐ-ํ๋ฆฌ 3D ์์ (Pixabay 3D) ๊ฒ์ โ 3D/GLB ์์ฒญ์ ์ฌ์ดํธ ์ถ์ฒ ๋์ ์ค์ ๊ฒ์ | |
| self.search_3d_model, | |
| # [๐ญ ๊ด์ธก ๋๊ตฌ] ์ฝ๊ธฐ ์ ์ฉ ์์คํ ๊ด์ธก โ ํ๊ฒฝ ์ง์์ ํ์ฅ ์ฌ๊ฒ์ฆ | |
| self.check_port, | |
| self.find_process, | |
| # [๐ ์ฌ์] ํ ์ํธ์ ์ง์ ์ ์ ์ฌ์ฌ (์๋์ฐ ๋ธ๋ ์ธ = ์ง์ ์ฌ์) | |
| self.review_knowledge_proposals, | |
| self.judge_knowledge_proposal, | |
| # [๐ฑ Imperial Fix] CLI/ํ์ผ ์ฐ์ ๋๊ตฌ๋ฅผ ์์ชฝ์ ๋ฐฐ์นํ์ฌ ๊ฒฝ๋ ๋ชจ๋ธ์ด ์ฐ์ ๊ณ ๋ คํ๊ฒ ํจ. | |
| # CLI-First Tools | |
| self.jarvis_memory, | |
| self.jarvis_system, | |
| self.jarvis_skill, | |
| self.jarvis_soul, | |
| self.jarvis_gbb_post, | |
| self.generate_image, # ๐จ Imperial Image Generation Tool | |
| self.sovereign_type_text, | |
| self.sovereign_press_key, | |
| self.sovereign_run_app, # ๐ OS Application Launch Tool | |
| self.delegate_team_task, # ๐๏ธ Team Autonomous Delegation Tool | |
| self.delegate_to_guardian, # ๐ค ๋๋ฃ ์ํธ์ ์์ (์ฝ๋ฑ์ค/๋ฏธ๋๋งฅ์ค/์๋น์ค ๋ฑ) | |
| self.dispatch_to_hydra, # ๐ฒ Hydra Parallel Planning Dispatch Tool | |
| # [๐ฑ Imperial Fix v2.7] Hugin(๋ธ๋ผ์ฐ์ ์กฐ์) ๋๊ตฌ๋ ๋งจ ๋ค๋ก ์ด๋. | |
| # ๋ชจ๋ธ์ด ํ์ผ ์์ /์ด๋ฏธ์ง ๋ถ์ ์์ฒญ์์ ์ต๊ด์ ์ผ๋ก hugin_screenshot์ ๋จผ์ | |
| # ํธ์ถํ๋ ๋ฌธ์ ๋ฅผ ๋ง๊ธฐ ์ํจ. (๋ช ์์ ๋ธ๋ผ์ฐ์ ์กฐ์ ์์ฒญ์์๋ง ์ฌ์ฉ๋๋๋ก ์ ๋) | |
| self.browser.hugin_navigate, | |
| self.browser.hugin_screenshot, | |
| ] | |
| # ๐๏ธ [Obsidian] ์ต์๋์ธ ๋ฐ Graphify ๋๊ตฌ๋ฅผ Gemini AFC ๋๊ตฌ ์ฃผ๋จธ๋์ ์ถ๊ฐ. | |
| try: | |
| import obsidian_router as _ov | |
| if _ov.is_any_enabled(): | |
| self._tools += [self.obsidian_search, self.obsidian_read, self.obsidian_write, self.project_graph_query] | |
| logger.info("[๐๏ธ Obsidian] ์ต์๋์ธ ๋๊ตฌ 4์ข (์ง์ ๊ทธ๋ํ ํฌํจ)์ Gemini ๋๊ตฌ ์ฃผ๋จธ๋์ ์ถ๊ฐ.") | |
| except Exception as _obs_err: | |
| logger.warning(f"[๐๏ธ Obsidian] ๋๊ตฌ ์ถ๊ฐ ๊ฑด๋๋: {_obs_err}") | |
| # ๐ฑ [Imperial v4.0] Dynamic MCP Tools injection for tool unification | |
| if get_mcp_client: | |
| try: | |
| mcp_client = get_mcp_client() | |
| if mcp_client: | |
| mcp_client.wait_ready(timeout=15.0) # [๐ฑ Imperial Fix] npx ๋ค์ด๋ก๋ ๋ฐ ์คํ ์๊ฐ์ ๊ณ ๋ คํ์ฌ 15์ด ๋๊ธฐ | |
| mcp_callables = mcp_client.get_gemini_callables() | |
| if mcp_callables: | |
| self._tools.extend(mcp_callables) | |
| logger.info(f"[๐ฑ Imperial Core] MCP ๋๊ตฌ {len(mcp_callables)}๊ฐ๋ฅผ ์ฑํ ์์ง ๋๊ตฌ ์ฃผ๋จธ๋์ ํตํฉ ์๋ฃ.") | |
| except Exception as mcp_init_err: | |
| logger.warning(f"[๐ฑ Imperial Core] MCP ๋๊ตฌ ์ฃผ์ ์ค ์์ธ ๋ฐ์: {mcp_init_err}") | |
| # AFC Tool Result Tracker (for empty response fallback) | |
| self._afc_tool_results = [] | |
| # [๐ฑ Imperial Fix v2.7] MCP ๋๊ตฌ ์คํ ๊ฒฐ๊ณผ๋ฅผ _afc_tool_results๋ก ํ๋ ค๋ณด๋ด๋ ์ฑํฌ ์ฐ๊ฒฐ. | |
| # AFC๊ฐ mcp_filesystem_write_file ๋ฑ์ ํธ์ถํด ์ฑ๊ณตํด๋ ๋ชจ๋ธ์ด ์ต์ข ํ ์คํธ๋ฅผ ์ ๋ง๋ค๋ฉด | |
| # ํ๋ฉด์ ์๋ฌด๊ฒ๋ ์ ๋จ๋ ๋ฌธ์ ํด๊ฒฐ โ MCP ๊ฒฐ๊ณผ๋ ํ๋ฉด์ ํ์๋๊ฒ ํ๋ค. | |
| if get_mcp_client: | |
| try: | |
| _mc = get_mcp_client() | |
| if _mc: | |
| _mc._result_sink = self._afc_tool_results | |
| except Exception: | |
| pass | |
| # Copilot Cache | |
| self._copilot_token = None | |
| self._copilot_expires = 0 | |
| self._copilot_url = "https://api.individual.githubcopilot.com" | |
| # [IMPERIAL EXTENSION] Configurable Primary/Fallback | |
| self.primary_provider = "google" | |
| self.primary_model = "gemini-3.1-flash-lite" | |
| self.fallback_provider = "github" | |
| self.fallback_model = "gpt-4o" | |
| self.search_grounding_model = "gemini-2.5-flash" | |
| self.ollama_host = os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434") | |
| # [๐ฑ OpenRouter] API Key (Bitwarden: Imperial:OPENROUTER_CLAUDE_TOKEN) | |
| self.openrouter_api_key = os.environ.get("OPENROUTER_API_KEY", "") | |
| if not self.openrouter_api_key: | |
| self.openrouter_api_key = sovereign_memory.get_secret("OPENROUTER_CLAUDE_TOKEN") | |
| if self.openrouter_api_key: | |
| logger.info("[OpenRouter] API Key recovered from Aegis Oracle.") | |
| # [๐ฑ NVIDIA] NVIDIA NIM API Keys. | |
| # MiniMax-M3 uses its own key. Do not fall back to the M2.7/general key; | |
| # NVIDIA accepts model-scoped keys and a wrong key returns auth/model errors. | |
| # OpenAI ํธํ ์๋ํฌ์ธํธ: https://integrate.api.nvidia.com/v1 | |
| # โ ๏ธ env์ 'Imperial:...' ํ๋ ์ด์คํ๋(๋ฏธํด๊ฒฐ)๊ฐ ๋ค์ด์ค๋ฉด ๊ทธ๋๋ก Bearer๋ก ๋๊ฐ 401์ด ๋๋ค โ ๋ฐ๋์ ๊ฑธ๋ฌ๋ผ ๊ฒ. | |
| def _normalize_nvidia_key(val): | |
| val = (val or "").strip().strip('"').strip("'") | |
| if val.lower().startswith("bearer "): | |
| val = val[7:].strip() | |
| return val | |
| def _resolve_nvidia_key(env_names): | |
| for env_name in env_names: | |
| val = _normalize_nvidia_key(os.environ.get(env_name)) | |
| source = "environment" | |
| if val and val.startswith(("BW_ID:", "Imperial:")): | |
| val = None | |
| if not val: | |
| val = _normalize_nvidia_key(sovereign_memory.get_secret(env_name)) | |
| source = "Aegis Oracle" | |
| if val and val.startswith(("BW_ID:", "Imperial:")): | |
| val = None | |
| if not val: | |
| for item_name in (f"Imperial:{env_name}", f"Imperial: {env_name}"): | |
| try: | |
| bw = subprocess.run( | |
| ["bw", "get", "password", item_name, "--raw"], | |
| capture_output=True, | |
| text=True, | |
| encoding="utf-8", | |
| errors="replace", | |
| timeout=10, | |
| ) | |
| candidate = _normalize_nvidia_key(bw.stdout) | |
| if bw.returncode == 0 and candidate and not candidate.startswith(("BW_ID:", "Imperial:")): | |
| val = candidate | |
| source = f"Bitwarden item {item_name}" | |
| break | |
| except Exception: | |
| pass | |
| if val: | |
| os.environ[env_name] = val | |
| logger.info(f"[NVIDIA] API Key recovered from {source} (via {env_name}).") | |
| return val | |
| return None | |
| self.nvidia_api_key = _resolve_nvidia_key(("NVIDIA_API_KEY",)) | |
| self.nvidia_minimax_m3_api_key = None # ํตํฉ: NVIDIA_API_KEY ๋จ์ผ ํค ์ฌ์ฉ | |
| # [๐ฑ GLM / Z.ai] zai-org GLM ๋ชจ๋ธ (modal.direct OpenAI-compat ์๋ํฌ์ธํธ) | |
| glm_raw = (os.environ.get("GLM_API_KEY", "") or "").strip() | |
| if not glm_raw or glm_raw.startswith(("BW_ID:", "Imperial:")): | |
| glm_raw = _normalize_nvidia_key(sovereign_memory.get_secret("GLM_API_KEY")) or "" | |
| if not glm_raw or glm_raw.startswith(("BW_ID:", "Imperial:")): | |
| for _item in ("Imperial:GLM_API_KEY", "Imperial: GLM_API_KEY"): | |
| try: | |
| _bw = subprocess.run( | |
| ["bw", "get", "password", _item, "--raw"], | |
| capture_output=True, text=True, encoding="utf-8", | |
| errors="replace", timeout=10, | |
| ) | |
| _c = _normalize_nvidia_key(_bw.stdout) | |
| if _bw.returncode == 0 and _c and not _c.startswith(("BW_ID:", "Imperial:")): | |
| glm_raw = _c | |
| break | |
| except Exception: | |
| pass | |
| if glm_raw: | |
| os.environ["GLM_API_KEY"] = glm_raw | |
| logger.info("[GLM] API Key resolved (Bitwarden/Aegis).") | |
| self.glm_api_key = glm_raw or None | |
| # [๐ฑ Imperial Atlas] Dynamic Port Discovery | |
| self.atlas_broker_url = os.environ.get("ATLAS_BROKER_URL", "http://127.0.0.1:18799") | |
| self._atlas_assigned_port = None | |
| self._load_shadow_settings() | |
| self.dynamic_config = self.get_dynamic_config() | |
| # [๐ฑ V7.0 Stateless Client Cache] | |
| self._clients = {} # {api_key: genai.Client} | |
| if self.api_key and genai and hasattr(self, 'client'): | |
| self._clients[self.api_key] = self.client | |
| def _classify_job_by_prompt(self, prompt: str) -> str: | |
| """์ฌ์ฉ์์ ์ง๋ฌธ(prompt)์ ๋ถ์ํ์ฌ ์ต์ ์ ์ง์ ID๋ฅผ ์๋ ํ๋ณํฉ๋๋ค. | |
| ๊ธฐ๋ณธ ๋ด์ฅ๋ ์ง์ ๋งคํ: | |
| - obsidian / ๋ ธํธ / ์ํค / ์ผ์ง / ๋งํฌ๋ค์ด ์์ฑ -> file_artisan (ํ์ผ ์ฅ์ธ - ์ต์๋์ธ ๋๊ตฌ ํฌํจ) | |
| - ๊ฒ์ / ๊ตฌ๊ธ / ์น์ํ / ์ต์ ์ ๋ณด / ์ฐพ์์ค -> scout (์ ์ฐฐ๋ณ) | |
| - ๋ธ๋ผ์ฐ์ / hugin / ์คํฌ๋ฆฐ์ท / ์บก์ณ / ์ฌ์ดํธ ๋ฐฉ๋ฌธ -> browser_pilot (๋ธ๋ผ์ฐ์ ์กฐ์ข ์ฌ) | |
| - ํ / ํ๋๋ผ / ์์ / ์ํ -> commander (์งํ๊ด) | |
| - ๊ทธ๋ ค์ค / ์ด๋ฏธ์ง / ์์ฑ / ๊ทธ๋ฆผ -> artist (์ฐฝ์๊ฐ) | |
| """ | |
| p = prompt.lower() | |
| # 1. ๋ธ๋ผ์ฐ์ ์กฐ์ข ์ฌ ์๋ ์ฐ์ (hugin, ๋ธ๋ผ์ฐ์ ์ ์ด) | |
| if any(k in p for k in ["hugin", "๋ธ๋ผ์ฐ์ ", "์คํฌ๋ฆฐ์ท", "์บก์ณ", "์บก์ฒ", "์นํ์ด์ง ์ด", "์ฌ์ดํธ ๋ฐฉ๋ฌธ", "ํ๋ ์ด๋ผ์ดํธ", "playwright"]): | |
| return "browser_pilot" | |
| # 2. ์ฐฝ์๊ฐ ์๋ (๊ทธ๋ ค์ค, ์ด๋ฏธ์ง ์์ฑ) | |
| if any(k in p for k in ["๊ทธ๋ ค์ค", "์ด๋ฏธ์ง", "๊ทธ๋ฆผ", "์์ฑ", "ํํญ", "๋จ์กฐ", "generate_image"]): | |
| # ๋จ, ๋จ์ ํ ์คํธ '์ฝ๋ ์์ฑ' ๋ฑ์ ์ ์ธ | |
| if "์ฝ๋" not in p and "ํ ์คํธ" not in p: | |
| return "artist" | |
| # 3. ์งํ๊ด ์๋ (์์, ํ๋๋ผ, ํ) | |
| if any(k in p for k in ["ํ๋๋ผ", "์์", "์ํ", "์ค์ผ์คํธ", "ํ ํ์คํฌ", "๋ค๋ฅธ ์ํธ์"]): | |
| return "commander" | |
| # 4. ํ์ผ ์ฅ์ธ (์ต์๋์ธ, ๋ก์ปฌ ํ์ผ ์ฐ๊ธฐ/์ฝ๊ธฐ) | |
| if any(k in p for k in ["์ต์๋์ธ", "obsidian", "๋ ธํธ", "์ํค", "์ผ์ง", "์ผ๊ธฐ", "๊ธ์ฐ๊ธฐ", "ํ์ผ", "ํด๋", "์์ ", "์ ์ฅ", "๊ธฐ๋ก"]): | |
| return "file_artisan" | |
| # 5. ์ ์ฐฐ๋ณ ์๋ (๊ฒ์, ์ต์ ์ ๋ณด) | |
| if any(k in p for k in ["๊ฒ์", "๊ตฌ๊ธ", "๊ตฌ๊ธ๋ง", "์ต์ ", "์ฐพ์", "์ ๋ณด ์์ง"]): | |
| return "scout" | |
| # 6. ๊ธฐ๋ณธ๊ฐ์ ๋ง๋ฅ ์ํธ์ | |
| return "" | |
| def get_dynamic_config(self, token=None, override_config=None, aegis_url=None): | |
| """[๐ฑ Imperial] ์ฃผ์ ๋ ์ค์ ๋๋ DB/Aegis API๋ก๋ถํฐ ์ค์ ์ ๋๊ธฐํํฉ๋๋ค.""" | |
| # 1. ๋ช ์์ ์ผ๋ก ์ฃผ์ ๋ ์ค์ ์ด ์๋ค๋ฉด ์ต์ฐ์ (Pure Intelligence Mode) | |
| if override_config: | |
| logger.info("[๐ง Config] Using injected override configuration.") | |
| return override_config | |
| # [๐ก๏ธ] Default Config (Fallback) | |
| default_config = { | |
| "provider": "google", | |
| "model": "gemini-3.1-flash-lite", | |
| "temperature": 0.7, | |
| "max_iterations": 15 | |
| } | |
| try: | |
| from db_util import get_db_connection | |
| conn = get_db_connection() | |
| # 2. DB ์ฐ๊ฒฐ์ด ์๋ ๊ฒฝ์ฐ Aegis API ์๋ | |
| if not conn: | |
| target_aegis = aegis_url or os.environ.get("AEGIS_URL", "").rstrip("/") | |
| if not target_aegis: | |
| return default_config # Zero-Config Fallback | |
| auth_token = token or os.environ.get("IMPERIAL_API_TOKEN") | |
| persona_id = sovereign_memory.get_secret("SHADOW_BRAIN_PERSONA_ID", default="a8e1a1af-f28b-47ab-b371-193082508638") | |
| api_url = f"{target_aegis}/api/chat/settings/{persona_id}" | |
| resp = requests.get(api_url, headers={"X-Imperial-Token": auth_token}, timeout=3) | |
| if resp.status_code == 200: | |
| data = resp.json() | |
| if data.get('status') == 'SUCCESS': | |
| return data.get('config', default_config) | |
| return default_config | |
| # 3. DB ์ง์ ์กฐํ (Legacy Fallback) | |
| cur = conn.cursor() | |
| shadow_brain_id = sovereign_memory.get_secret("SHADOW_BRAIN_PERSONA_ID", default="a8e1a1af-f28b-47ab-b371-193082508638") | |
| cur.execute(""" | |
| SELECT ai_provider, ai_model, temperature, capabilities | |
| FROM taemingames.guardian_personas | |
| WHERE id = %s | |
| """, (shadow_brain_id,)) | |
| row = cur.fetchone() | |
| cur.close() | |
| conn.close() | |
| if row: | |
| capabilities = row[3] if isinstance(row[3], dict) else json.loads(row[3] or '{}') | |
| # [๐๏ธ Guardian Job M3] capabilities์ 'job' ํ๋ฆฌ์ ์ด ์์ผ๋ฉด ์ง์ ์ค์ ์ ๋ณํฉ. | |
| # - ์ฑ๋ฅ ํ ๊ธ(memory_recall ๋ฑ)๊ณผ num_ctx๋ '์ง์ ์ด ๋จ์ผ ์ง์ค'์ด๋ฏ๋ก | |
| # allowed_tools ์ ๋ฌด์ ๋ฌด๊ดํ๊ฒ ํญ์ ๋ณํฉํ๋ค. | |
| # - ๋๊ตฌ/์คํฌ/๋ฃฐ์ allowed_tools๋ฅผ ์ง์ ์ง์ ํ์ง ์์ ๊ฒฝ์ฐ์๋ง ์ง์ ์ผ๋ก ๋ฎ์ด์ด๋ค. | |
| if capabilities.get("job"): | |
| try: | |
| from guardian_jobs import get_job as _get_job | |
| _job = _get_job(capabilities.get("job")) or {} | |
| for _k in ("memory_recall", "proactive_context", "stage_timing", "inject_guidelines", "use_history", "use_persona", "inject_emotion", "inject_readability"): | |
| capabilities[_k] = bool(_job.get(_k, True)) | |
| capabilities["obsidian_job_path"] = str(_job.get("obsidian_job_path") or "") | |
| if _job: | |
| capabilities["job_source"] = str(_job.get("source") or "vault").lower() | |
| if _job.get("num_ctx"): | |
| capabilities["num_ctx"] = int(_job.get("num_ctx") or 0) | |
| if _job.get("num_predict"): | |
| capabilities["num_predict"] = int(_job.get("num_predict") or 0) | |
| if not capabilities.get("allowed_tools"): | |
| # ๐ฎ [์ฑ ์ธํธ] ์ฅ์ฐฉ ์ฑ(equipped_apps)์ ๋๊ตฌ/์คํฌ๋ก ํผ์ณ ํฉ์งํฉ. | |
| try: | |
| from guardian_jobs import resolve_effective_tools_skills as _resolve_ets | |
| _eff_tools, _eff_skills = _resolve_ets(_job) | |
| except Exception: | |
| _eff_tools = list(_job.get("allowed_tools") or []) | |
| _eff_skills = list(_job.get("equipped_skills") or []) | |
| capabilities["allowed_tools"] = _eff_tools | |
| capabilities["tool_mode"] = _job.get("tool_mode", "all") | |
| capabilities["equipped_skills"] = _eff_skills | |
| capabilities["job_rule"] = _job.get("rule", "") | |
| except Exception as _job_err: | |
| logger.warning(f"[Guardian Job M3] ์ง์ ํ๋ฆฌ์ ์ ์ฉ ์คํจ: {_job_err}") | |
| return { | |
| "provider": row[0] or "google", | |
| "model": row[1] or "gemini-3.1-flash-lite", | |
| "temperature": row[2] or 0.7, | |
| "max_iterations": capabilities.get("max_iterations", 10), | |
| # [๐ฑ Guardian Job M1] ์ง์ (๋๊ตฌ/์คํฌ ํนํ) ํ๋ ํต๊ณผ | |
| "allowed_tools": capabilities.get("allowed_tools"), | |
| "tool_mode": capabilities.get("tool_mode", "all"), | |
| "equipped_skills": capabilities.get("equipped_skills", []), | |
| "job_rule": capabilities.get("job_rule", ""), # ์ง์ ๊ธฐ๋ณธ ๋ฃฐ | |
| # [๐๏ธ Guardian Job] Ollama ์ปจํ ์คํธ ์๋์ฐ(์ง์ ๋ณ ์๋/์ ํ๋ ํ๋). 0/๋ฏธ์ง์ ์ด๋ฉด ์๋. | |
| "num_ctx": capabilities.get("num_ctx", 0), | |
| "num_predict": capabilities.get("num_predict", 0), | |
| # ๐ ์ฑ๋ฅ ํ ๊ธ (์ง์ ๋ณ) | |
| "memory_recall": capabilities.get("memory_recall", True), | |
| "proactive_context": capabilities.get("proactive_context", True), | |
| "stage_timing": capabilities.get("stage_timing", True), | |
| "inject_guidelines": capabilities.get("inject_guidelines", True), | |
| "inject_emotion": capabilities.get("inject_emotion", True), | |
| "inject_readability": capabilities.get("inject_readability", True), | |
| "use_history": capabilities.get("use_history", True), | |
| "use_persona": capabilities.get("use_persona", True), | |
| "obsidian_job_path": capabilities.get("obsidian_job_path", ""), | |
| "job_source": capabilities.get("job_source", "cloud"), | |
| } | |
| except Exception: | |
| pass | |
| return default_config | |
| def _load_shadow_settings(self): | |
| """Loads Shadow Brain configuration from the shared workspace.""" | |
| try: | |
| proj_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| settings_file = os.path.join(proj_root, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_SHADOW_SETTINGS.json") | |
| if os.path.exists(settings_file): | |
| with open(settings_file, 'r', encoding='utf-8') as f: | |
| settings = json.load(f) | |
| self.primary_provider = settings.get("provider", "google") | |
| self.primary_model = settings.get("model", "gemini-3.1-flash-lite") | |
| self.fallback_provider = settings.get("fallbackProvider", "github") | |
| self.fallback_model = settings.get("fallbackModel", "gpt-4o") | |
| self.web_search_enabled = settings.get("webSearchEnabled", self.web_search_enabled) | |
| self.search_grounding_model = settings.get("searchGroundingModel") or settings.get("search_grounding_model") or self.search_grounding_model | |
| self.ollama_host = settings.get("ollamaHost") or settings.get("ollama_host") or self.ollama_host | |
| # ๐ฑ ์ํ๋ผ์ค ๊ด์ ๊ด๋ฌธ(Broker Port) ์ฐ๋ | |
| atlas_port = settings.get("atlasBrokerPort", 18799) | |
| self.atlas_broker_url = f"http://localhost:{atlas_port}" | |
| logger.info(f"[STATUS] Imperial Brain Configured: Primary={self.primary_model}, Port={atlas_port}, WebSearch={self.web_search_enabled}") | |
| except Exception as e: | |
| logger.warning(f"Failed to load Shadow settings: {e}") | |
| def _execute_cli(self, script_name: str, args: List[str]) -> str: | |
| """Helper to execute external CLI scripts safely.""" | |
| try: | |
| proj_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| script_path = os.path.join(proj_root, "scripts", "cli", script_name) | |
| # [๐ก๏ธ] Dynamic Token Delegation: Pass auth_token to CLI tools | |
| env = os.environ.copy() | |
| if hasattr(self, 'auth_token') and self.auth_token: | |
| env["IMPERIAL_API_TOKEN"] = self.auth_token | |
| env["X_IMPERIAL_TOKEN"] = self.auth_token | |
| # Capture raw bytes to handle Windows encoding (CP949) vs UTF-8 conflicts | |
| result = subprocess.run(cmd, capture_output=True, timeout=60, env=env) | |
| def _multi_decode(data: bytes) -> str: | |
| if not data: return "" | |
| for enc in ['utf-8', 'cp949', 'euc-kr']: | |
| try: | |
| return data.decode(enc) | |
| except UnicodeDecodeError: | |
| continue | |
| return data.decode('utf-8', errors='replace') | |
| stdout_str = _multi_decode(result.stdout) | |
| stderr_str = _multi_decode(result.stderr) | |
| if result.returncode == 0: | |
| self._afc_tool_results.append(stdout_str.strip() if stdout_str.strip() else f"โ {script_name} ์คํ ์๋ฃ") | |
| return stdout_str | |
| else: | |
| return f"Error ({result.returncode}): {stderr_str}" | |
| except Exception as e: | |
| logger.error(f"CLI Execution Failed ({script_name}): {e}") | |
| return f"Critical Error: {str(e)}" | |
| def _get_mcp_allowed_dirs(self): | |
| """mcp_servers.json์ filesystem ํ์ดํธ๋ฆฌ์คํธ ํด๋ ๋ชฉ๋ก์ ์ฝ๋๋ค (์คํจ ์ ๋น ๋ชฉ๋ก).""" | |
| try: | |
| try: | |
| from mcp_client import resolve_mcp_config_path | |
| cfg_path = resolve_mcp_config_path() | |
| except Exception: | |
| proj_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| cfg_path = os.path.join(proj_root, 'mcp_servers.json') | |
| with open(cfg_path, 'r', encoding='utf-8') as f: | |
| config = json.load(f) | |
| for server in config.get('servers', []): | |
| if server.get('name') == 'filesystem': | |
| args = server.get('args', []) | |
| if "@modelcontextprotocol/server-filesystem" in args: | |
| idx = args.index("@modelcontextprotocol/server-filesystem") | |
| return [str(d) for d in args[idx + 1:]] | |
| except Exception: | |
| pass | |
| return [] | |
| def _resolve_aegis(self): | |
| """์์ฒญ ์ปจํ ์คํธ(์ฐ์ ) ๋๋ ํ๊ฒฝ๋ณ์์์ Aegis URL/ํ ํฐ์ ํด์ํ๋ค.""" | |
| ctx = getattr(self, "_request_ctx", None) | |
| aegis_url = (getattr(ctx, "aegis_url", None) if ctx else None) or os.environ.get("AEGIS_URL", "") | |
| token = (getattr(ctx, "token", None) if ctx else None) or os.environ.get("IMPERIAL_API_TOKEN", "") | |
| return (aegis_url or "").rstrip("/"), token | |
| def search_knowledge(self, query: str, limit: int = 5) -> str: | |
| """ | |
| Searches the Imperial Knowledge Base (curated operational facts: config paths, ports, | |
| system locations, architecture decisions). MUST be used before answering questions about | |
| system configuration, file locations, or how Imperial infrastructure works. | |
| Args: | |
| query: Keywords to search (e.g., 'OpenClaw ์ค์ ๊ฒฝ๋ก', 'Hermes ํ๋ก์ ํฌํธ') | |
| limit: Max facts to return (default 5) | |
| """ | |
| try: | |
| aegis_url, token = self._resolve_aegis() | |
| if not aegis_url: | |
| return "์ง์ ๋ฒ ์ด์ค ์ ๊ทผ ๋ถ๊ฐ: Aegis URL ๋ฏธ์ค์ ." | |
| import platform | |
| resp = requests.get( | |
| f"{aegis_url}/api/knowledge", | |
| params={"q": query, "limit": int(limit or 5), "env": platform.node()}, | |
| headers={"X-Imperial-Token": token}, timeout=5, | |
| ) | |
| data = resp.json() if resp.status_code == 200 else {} | |
| results = data.get("results", []) | |
| if not results: | |
| return ("์ด์ ์ง์ ๋ฒ ์ด์ค ๊ฒ์ ๊ฒฐ๊ณผ: ๊ด๋ จ ์ง์ ์์. " | |
| "์ถ์ธกํ์ง ๋ง๊ณ '๋ฑ๋ก๋ ์ง์์ด ์์ด ํ์ธ์ด ํ์ํ๋ค'๊ณ ๋ตํ๋ผ. " | |
| "์ดํ ์ฌ์ค์ ํ์ธํ๋ฉด save_knowledge๋ก ๊ธฐ๋กํ๋ผ.") | |
| lines = [] | |
| for i, r in enumerate(results, 1): | |
| verified = (r.get("verified_at") or "")[:10] | |
| scope_tag = "๐๋ณดํธ" if r.get("scope") == "global" else f"๐ป์ด ํ๊ฒฝ({r.get('environment_id')})" | |
| line = f"{i}. [{r.get('topic')}|{scope_tag}] {r.get('fact')} (ํ์ธ: {verified}, ๋ฑ๋ก: {r.get('registered_by')})" | |
| if r.get("verify_hint"): | |
| line += f"\n โณ ์ฌ๊ฒ์ฆ ๋ฐฉ๋ฒ: {r['verify_hint']}" | |
| lines.append(line) | |
| return ("์ด์ ์ง์ ๋ฒ ์ด์ค ๊ฒ์ ๊ฒฐ๊ณผ:\n" + "\n".join(lines) + | |
| "\n๊ท์น(์ ๋ขฐ ๋ฑ๊ธ): ๐๋ณดํธ ์ง์์ ๊ทธ๋๋ก ์ ๋ขฐ ๊ฐ๋ฅ. ๐ปํ๊ฒฝ ์ง์์ '๊ธฐ๋ก์' ์ฌ์ค์ด๋ฏ๋ก, " | |
| "์ฌ๊ฒ์ฆ ๋ฐฉ๋ฒ์ด ์์ผ๋ฉด check_port/find_process ๋ฑ์ผ๋ก ์ง๊ธ ์ด ํ๊ฒฝ์์ ํ์ธํ ๋ค ๋จ์ ํ๋ผ. " | |
| "๊ฒ์ฆ ์์ด ๋ตํ ๋๋ '๊ธฐ๋ก ๊ธฐ์ค(ํ์ธ์ผ ๋ช ์)'์์ ๋ฐํ๋ผ. ํ์ธ์ผ์ด ์ค๋๋์ผ๋ฉด ๊ทธ ์ ๋ ์๋ ค๋ผ.") | |
| except Exception as e: | |
| return f"์ง์ ๋ฒ ์ด์ค ๊ฒ์ ์คํจ: {e}. ๊ฒ์์ด ์ ๋์ผ๋ฏ๋ก ์์คํ ๊ตฌ์ฑ์ ๋จ์ ํ์ง ๋ง๋ผ." | |
| def search_media(self, query: str, media_type: str = "photo", provider: str = "pexels", limit: int = 8) -> str: | |
| """ | |
| ๋ก์ดํฐ-ํ๋ฆฌ(๋ฌด๋ฃ) ๋ฏธ๋์ด๋ฅผ ๊ฒ์ํ๋ค. Pexels/Pixabay ์ฌ์งยท์์ยทGIF๋ฅผ ํค์๋๋ก ์ฐพ์ | |
| ์๋ณธ/๋ค์ด๋ก๋/์ถ์ฒ URL์ ๋ฐํํ๋ค. ์ฌ์ฉ์๊ฐ ์ด๋ฏธ์ง/์ฌ์ง/์์/GIF/๋ฐฐ๊ฒฝํ๋ฉด/์คํก ๋ฏธ๋์ด๋ฅผ | |
| ์ฐพ์๋ฌ๋ผ๊ณ ํ๋ฉด ์ถ์ธก(์ฌ์ดํธ ์ถ์ฒ)ํ์ง ๋ง๊ณ ์ด ๋๊ตฌ๋ก ์ค์ ๊ฒ์ํ๋ผ. | |
| Args: | |
| query: ๊ฒ์ ํค์๋ (์: '๊ณ ์์ด', 'fire') | |
| media_type: 'photo' | 'video' | 'gif' (๊ธฐ๋ณธ photo) | |
| provider: 'pexels' | 'pixabay' (photo/video์ฉ, ๊ธฐ๋ณธ pexels. gif๋ ๋ฌด์) | |
| limit: ๋ฐํ ๊ฐ์ 1-20 (๊ธฐ๋ณธ 8) | |
| """ | |
| try: | |
| aegis_url, token = self._resolve_aegis() | |
| if not aegis_url: | |
| aegis_url = os.environ.get("AEGIS_URL", "http://localhost:18701").rstrip("/") | |
| try: | |
| lim = max(1, min(20, int(limit or 8))) | |
| except (TypeError, ValueError): | |
| lim = 8 | |
| resp = requests.get( | |
| f"{aegis_url}/api/wallpaper/search", | |
| params={"q": query, "type": media_type, "provider": provider}, | |
| headers={"X-Imperial-Token": token} if token else {}, | |
| timeout=25, | |
| ) | |
| if resp.status_code != 200: | |
| return f"๋ฏธ๋์ด ๊ฒ์ ์คํจ: HTTP {resp.status_code}. ๊ฒฐ๊ณผ๋ฅผ ๋จ์ ํ์ง ๋ง๋ผ." | |
| data = resp.json() | |
| if data.get("status") != "ok": | |
| return f"๋ฏธ๋์ด ๊ฒ์ ์คํจ: {data.get('message', 'unknown')}" | |
| hits = (data.get("hits") or [])[:lim] | |
| if not hits: | |
| return f"'{query}' ({media_type}) ๊ฒ์ ๊ฒฐ๊ณผ ์์. ๋ค๋ฅธ ๊ฒ์์ด๋ฅผ ์ ์ํ๋ผ." | |
| lines = [f"'{query}' {media_type} ๋ก์ดํฐ-ํ๋ฆฌ ๊ฒ์ ๊ฒฐ๊ณผ ({len(hits)}๊ฑด, ๋ชจ๋ ๋ฌด๋ฃ ์ฌ์ฉ ๊ฐ๋ฅ):"] | |
| for i, h in enumerate(hits, 1): | |
| dl = h.get("download", "") | |
| if isinstance(dl, str) and dl.startswith("/"): | |
| dl = aegis_url + dl | |
| lines.append( | |
| f"{i}. [{h.get('source')}|{h.get('type')}] {h.get('url')}" | |
| f"\n โฌ ๋ค์ด๋ก๋: {dl}\n ๐ ์ถ์ฒ: {h.get('page')}" | |
| ) | |
| lines.append("๊ท์น: ์ค์ ๊ฒ์๋ ๊ฒฐ๊ณผ๋ค. URL์ ๊ทธ๋๋ก ์ฌ์ฉ์์๊ฒ ์ ์ํ๋ผ(์ฌ์ดํธ ์ถ์ฒ์ผ๋ก ๋์ฒดํ์ง ๋ง ๊ฒ).") | |
| return "\n".join(lines) | |
| except Exception as e: | |
| return f"๋ฏธ๋์ด ๊ฒ์ ์คํจ: {e}. ๊ฒฐ๊ณผ๋ฅผ ๋จ์ ํ์ง ๋ง๋ผ." | |
| def search_media_hits(self, query: str, media_type: str = "photo", provider: str = "pexels", limit: int = 8): | |
| try: | |
| aegis_url, token = self._resolve_aegis() | |
| if not aegis_url: | |
| aegis_url = os.environ.get("AEGIS_URL", "http://localhost:18701").rstrip("/") | |
| try: | |
| lim = max(1, min(20, int(limit or 8))) | |
| except (TypeError, ValueError): | |
| lim = 8 | |
| resp = requests.get( | |
| f"{aegis_url}/api/wallpaper/search", | |
| params={"q": query, "type": media_type, "provider": provider}, | |
| headers={"X-Imperial-Token": token} if token else {}, | |
| timeout=25, | |
| ) | |
| if resp.status_code != 200: | |
| return aegis_url, [], f"๋ฏธ๋์ด ๊ฒ์ ์คํจ: HTTP {resp.status_code}. ๊ฒฐ๊ณผ๋ฅผ ์ถ์ ํ์ง ๋ง๋ผ." | |
| data = resp.json() | |
| if data.get("status") != "ok": | |
| return aegis_url, [], f"๋ฏธ๋์ด ๊ฒ์ ์คํจ: {data.get('message', 'unknown')}" | |
| return aegis_url, (data.get("hits") or [])[:lim], None | |
| except Exception as e: | |
| return "", [], f"๋ฏธ๋์ด ๊ฒ์ ์คํจ: {e}. ๊ฒฐ๊ณผ๋ฅผ ์ถ์ ํ์ง ๋ง๋ผ." | |
| def search_3d_model(self, query: str, limit: int = 8, source: str = "both") -> list: | |
| """๐ง ๋ก์ดํฐ-ํ๋ฆฌ 3D ๋ชจ๋ธ์ ๊ฒ์ํด hit ๋ฆฌ์คํธ๋ฅผ ๋ฐํํ๋ค. | |
| source: 'pixabay' | 'sketchfab' | 'both'(๊ธฐ๋ณธ). | |
| ๋ฐํ: [{id, name, thumbnail_url, href, source, ...}, ...] (์คํจ/๊ฒฐ๊ณผ์์์ด๋ฉด []). | |
| Sketchfab hit์ embed_url/viewer_url/author๋ฅผ ์ถ๊ฐ๋ก ๋ด๋๋ค (ํค์๋ฌ์ด | |
| source๋ก ๋ทฐ์ด๋ฅผ ๋ถ๊ธฐ). ์ค์ ์ฑํ ์นด๋(imperial-3d ๋ธ๋ก) ๋ฐฉ์ถ์ ToolRuntime ๋ด๋น. | |
| """ | |
| try: | |
| aegis_url, token = self._resolve_aegis() | |
| if not aegis_url: | |
| aegis_url = os.environ.get("AEGIS_URL", "http://localhost:18701").rstrip("/") | |
| headers = {"X-Imperial-Token": token} if token else {} | |
| try: | |
| lim = max(1, min(20, int(limit or 8))) | |
| except (TypeError, ValueError): | |
| lim = 8 | |
| src = (source or "both").strip().lower() | |
| if src not in ("pixabay", "sketchfab", "both"): | |
| src = "both" | |
| def _fetch(path: str) -> list: | |
| try: | |
| resp = requests.get( | |
| f"{aegis_url}{path}", | |
| params={"q": query}, | |
| headers=headers, | |
| timeout=25, | |
| ) | |
| if resp.status_code != 200: | |
| logger.warning(f"[3D Search] {path} HTTP {resp.status_code}") | |
| return [] | |
| data = resp.json() | |
| if data.get("status") != "ok": | |
| logger.warning(f"[3D Search] {path} ์คํจ: {data.get('message')}") | |
| return [] | |
| return data.get("hits") or [] | |
| except Exception as e: # noqa: BLE001 | |
| logger.warning(f"[3D Search] {path} ์ค๋ฅ: {e}") | |
| return [] | |
| out = [] | |
| if src in ("sketchfab", "both"): | |
| for h in _fetch("/api/sketchfab/search"): | |
| if not h.get("embed_url"): | |
| continue | |
| out.append({ | |
| "id": str(h.get("id") or ""), | |
| "name": h.get("name") or "Sketchfab Model", | |
| "thumbnail_url": h.get("thumbnail_url") or "", | |
| "href": h.get("viewer_url") or "", | |
| "embed_url": h.get("embed_url") or "", | |
| "viewer_url": h.get("viewer_url") or "", | |
| "author": h.get("author") or "", | |
| "license": h.get("license") or "", | |
| "source": "sketchfab", | |
| }) | |
| if src in ("pixabay", "both"): | |
| for h in _fetch("/api/3d-models/search"): | |
| if not h.get("href"): | |
| continue | |
| out.append({ | |
| "id": str(h.get("id") or ""), | |
| "name": h.get("name") or "Pixabay 3D Model", | |
| "thumbnail_url": h.get("thumbnail_url") or "", | |
| "href": h.get("href") or "", | |
| "source": "pixabay", | |
| }) | |
| if src == "both" and out: | |
| # ๋ ์์ค๋ฅผ ๋ฒ๊ฐ์ ์์ด ํ์ชฝ์ด ์์๋ฅผ ๋ ์ ํ์ง ์๊ฒ ํ๋ค | |
| sf = [h for h in out if h["source"] == "sketchfab"] | |
| px = [h for h in out if h["source"] == "pixabay"] | |
| mixed = [] | |
| for i in range(max(len(sf), len(px))): | |
| if i < len(sf): | |
| mixed.append(sf[i]) | |
| if i < len(px): | |
| mixed.append(px[i]) | |
| out = mixed | |
| return out[:lim] | |
| except Exception as e: # noqa: BLE001 | |
| logger.error(f"[3D Search] ์ค๋ฅ: {e}") | |
| return [] | |
| def web_search(self, query: str, engine: str = "naver") -> str: | |
| """๐ ์ค์๊ฐ ์น๊ฒ์(์์ฌยท์ธ๋ฌผยท๋ด์คยท๋ ์จยท์์ธ ๋ฑ). | |
| 1์์๋ก Google ๊ฒ์ ๊ทธ๋ผ์ด๋ฉ(Gemini, ๋งค์ฐ ๋น ๋ฆ)์ ์ฐ๊ณ , ์คํจํ๋ฉด | |
| headless Python Playwright(๋ค์ด๋ฒโ๋ค์โ๊ตฌ๊ธ, ๋๋ฆผ)๋ก ํด๋ฐฑํ๋ค. | |
| ์ฌ์ฉ์๊ฐ ํน์ ์์ง(naver/daum/google)์ ๋ช ์ํ๋ฉด ๊ณง์ฅ Playwright๋ฅผ ์ด๋ค. | |
| (๊ตฌ๊ธ ์ง์ ์คํฌ๋ํ์ ๋ฐ์ดํฐ์ผํฐ IP ์บก์ฐจ๋ก ๋งํ๋ฏ๋ก ํด๋ฐฑ์ ๋ค์ด๋ฒ ์ฐ์ .)""" | |
| # ๐ฑ [Imperial Turbo Search] ์์ง ๋ฏธ์ง์ (๊ธฐ๋ณธ naver)์ด๋ฉด Google ๊ทธ๋ผ์ด๋ฉ ์ฐ์ . | |
| # ๋ช ์์ ์์ง ์ ํ(์ฌ์ฉ์๊ฐ ask_user์์ ๋ค์ด๋ฒ/๋ค์/๊ตฌ๊ธ ๊ณ ๋ฅธ ๊ฒฝ์ฐ)์ ์๋ ์กด์คํด Playwright ์งํ. | |
| if (engine or "naver").strip().lower() == "naver": | |
| try: | |
| aegis_url, token = self._resolve_aegis() | |
| if aegis_url: | |
| grounded = self._search_grounding_context(query, aegis_url=aegis_url, token=token) | |
| if grounded and grounded.strip(): | |
| return grounded | |
| except Exception as ge: # noqa: BLE001 | |
| logger.warning(f"[Web Search] Google ๊ทธ๋ผ์ด๋ฉ ์คํจ โ Playwright ํด๋ฐฑ: {ge}") | |
| try: | |
| return self.browser.local_playwright_search(query, engine=engine) | |
| except Exception as e: # noqa: BLE001 | |
| logger.error(f"[Web Search] ์ค๋ฅ: {e}") | |
| return f"์น๊ฒ์ ์คํจ: {e}" | |
| def youtube_search(self, query: str, limit: int = 5) -> str: | |
| """๐ฅ ์ค์๊ฐ ์ ํ๋ธ(YouTube) ๋์์ ๊ฒ์. | |
| ํน์ ํค์๋/์ฃผ์ ์ ๋ํ ์ค์๊ฐ ์ ํ๋ธ ์์ ์ ๋ณด๋ฅผ ๊ฒ์ํ์ฌ ์ธ๋ค์ผ๊ณผ ๋์์ ๋งํฌ ๋ชฉ๋ก์ ํ๋ํ๋ค. | |
| Args: | |
| query: ์ ํ๋ธ ๊ฒ์ ์ง์์ด (์: '์ฉ์ธ ์ฌํ ์ฝ์ค ์ถ์ฒ') | |
| limit: ๊ฐ์ ธ์ฌ ์ต๋ ๋์์ ๊ฐ์ (๊ธฐ๋ณธ 5) | |
| """ | |
| try: | |
| aegis_url, token = self._resolve_aegis() | |
| if not aegis_url: | |
| return "์ ํ๋ธ ๊ฒ์ ์คํจ: Aegis URL ๋ฏธ์ค์ ." | |
| url = f"{aegis_url.rstrip('/')}/api/youtube/search" | |
| headers = {} | |
| if token: | |
| headers['X-Imperial-Token'] = token | |
| headers['Authorization'] = f"Bearer {token}" | |
| resp = requests.get(url, params={'query': query}, headers=headers, timeout=6) | |
| if resp.status_code != 200: | |
| return f"์ ํ๋ธ ๊ฒ์ ์คํจ: API ์๋ต ์ฝ๋ {resp.status_code}" | |
| data = resp.json() | |
| videos = data.get('videos', [])[:limit] | |
| if not videos: | |
| return "์ ํ๋ธ ๊ฒ์ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค." | |
| video_blocks = [] | |
| for v in videos: | |
| video_blocks.append(f"- **[{v['title']}]({v['video_url']})** (์ฑ๋: {v['channel_title']})") | |
| json_block = { | |
| "query": query, | |
| "videos": videos | |
| } | |
| json_str = json.dumps(json_block, ensure_ascii=False, indent=2) | |
| result = ( | |
| f"์ ํ๋ธ ๊ฒ์ ์๋ฃ (ํค์๋: '{query}'):\n" | |
| + "\n".join(video_blocks) | |
| + f"\n\n```imperial-youtube\n{json_str}\n```" | |
| ) | |
| return result | |
| except Exception as e: | |
| logger.error(f"[YouTube Search] ์ค๋ฅ: {e}") | |
| return f"์ ํ๋ธ ๊ฒ์ ์ค ์์ธ ๋ฐ์: {e}" | |
| def save_knowledge(self, topic: str, fact: str, source: str = "", scope: str = "global", verify_hint: str = "") -> str: | |
| """ | |
| Saves a verified operational fact to the Imperial Knowledge Base, shared across ALL | |
| guardians. MUST be called whenever you discover/verify a new system fact (config path | |
| changed, port changed, new service location). Same topic updates the existing fact. | |
| Args: | |
| topic: Short subject (e.g., 'OpenClaw ์ค์ค์ ๊ฒฝ๋ก') | |
| fact: The verified fact in present tense | |
| source: How it was verified (e.g., '์คํ ํ๋ก์ธ์ค ์ปค๋งจ๋๋ผ์ธ ํ์ธ') | |
| scope: 'global' if true on any PC (product/architecture), 'environment' if only true on this machine (paths, running ports) | |
| verify_hint: How to re-verify this fact in a new environment (e.g., 'check_port 18789') | |
| """ | |
| try: | |
| aegis_url, token = self._resolve_aegis() | |
| if not aegis_url: | |
| return "์ง์ ์ ์ฅ ๋ถ๊ฐ: Aegis URL ๋ฏธ์ค์ . ์ ์ฅ๋๋ค๊ณ ๋งํ์ง ๋ง๋ผ." | |
| import platform | |
| scope = scope if scope in ("global", "environment") else "global" | |
| payload = {"topic": topic, "fact": fact, "source": source, | |
| "registered_by": "๊ทธ๋ฆผ์ ๋๋ (Shadow Brain)", | |
| "scope": scope, "verify_hint": verify_hint} | |
| if scope == "environment": | |
| payload["environment_id"] = platform.node() | |
| resp = requests.post( | |
| f"{aegis_url}/api/knowledge", | |
| json=payload, | |
| headers={"X-Imperial-Token": token}, timeout=5, | |
| ) | |
| data = resp.json() if resp.status_code == 200 else {} | |
| if data.get("status") == "SUCCESS": | |
| action = "๊ฐฑ์ " if data.get("action") == "updated" else "์ ๊ท ๋ฑ๋ก" | |
| return f"์ง์ ๋ฒ ์ด์ค ์ ์ฅ ์๋ฃ ({action}): [{topic}]" | |
| return f"์ง์ ์ ์ฅ ์คํจ (HTTP {resp.status_code}): {resp.text[:120]}. ์ ์ฅ๋๋ค๊ณ ๋งํ์ง ๋ง๋ผ." | |
| except Exception as e: | |
| return f"์ง์ ์ ์ฅ ์คํจ: {e}. ์ ์ฅ๋๋ค๊ณ ๋งํ์ง ๋ง๋ผ." | |
| def obsidian_search(self, query: str, limit: int = 8, source: str = None) -> str: | |
| """ | |
| Searches the Obsidian vault (curated/distilled knowledge in markdown notes) โ a | |
| knowledge layer separate from conversation memory. Use to find hand-organized notes, | |
| design docs, and reference material that the user has written. Works in both Local | |
| and Cloud (R2 Live) environments based on routing. | |
| Args: | |
| query: Keywords to search in note titles and bodies | |
| limit: Max notes to return (default 8) | |
| source: 'cloud' to search the R2 cloud vault (PGVector) even while running | |
| locally; omit/None for the active vault (local-first). | |
| """ | |
| try: | |
| import obsidian_router as ov | |
| n = int(limit or 8) | |
| if (source or "").strip().lower() == "cloud": | |
| vault = ov.get_cloud_vault() | |
| if vault is None: | |
| return "ํด๋ผ์ฐ๋(R2) ์ต์๋์ธ ๋ณผํธ๊ฐ ๋นํ์ฑ์ ๋๋ค(์๊ฒฉ์ฆ๋ช ์์). ๋จ์ ํ์ง ๋ง๋ผ." | |
| # PGVector ์๋ฏธ ๊ฒ์ ์ฐ์ , ์ฅ์ (None)๊ฑฐ๋ ๊ฒฐ๊ณผ๊ฐ ๋น๋ฉด([]) R2 ํค์๋/ํ์ผ๋ช | |
| # ๊ฒ์์ผ๋ก ๋ณด๊ฐํ๋ค. (์: ํ์ผ๋ช 'ttt' ๊ฐ์ ๋ฆฌํฐ๋ด ์กฐํ๋ ์๋งจํฑ๋ณด๋ค ํ์ผ๋ช | |
| # ๋งค์นญ์ด ์ ํ โ ํด๋ผ์ฐ๋๋ ๋ ธํธ ์๊ฐ ์ ์ด R2 ์ค์บ ๋น์ฉ๋ ์๋ค.) | |
| # ๐ ์๋ฒ ๋ฉ(OpenAI ํค)์ Aegis ๊ฒฝ์ ๋ผ aegis_url/token์ด ์์ด์ผ syncยท๊ฒ์์ด | |
| # ๋์ํ๋ค. ์์ผ๋ฉด PGVector๊ฐ ๋น๊ณ (์ฐ๊ธฐ ์คํจ) ๊ฒ์๋ None โ R2 ํด๋ฐฑ๋ง ๋๋ค. | |
| _ctx = getattr(self, "_request_ctx", None) | |
| _aegis = getattr(_ctx, "aegis_url", None) if _ctx else None | |
| _tok = getattr(_ctx, "token", None) if _ctx else None | |
| results = None | |
| try: | |
| import cloud_obsidian_vector | |
| results = cloud_obsidian_vector.search( | |
| query, limit=n, aegis_url=_aegis, token=_tok | |
| ) | |
| except Exception: | |
| results = None | |
| if not results: | |
| results = vault.search_notes(query, limit=n) | |
| else: | |
| vault = ov.get_vault() | |
| if vault is None: | |
| return "์ต์๋์ธ ๋ณผํธ๊ฐ ๋นํ์ฑ์ ๋๋ค. ์ด ํ๊ฒฝ์์๋ ์ฌ์ฉํ ์ ์์ผ๋ ๋จ์ ํ์ง ๋ง๋ผ." | |
| results = vault.search_notes(query, limit=n) | |
| if not results: | |
| return f"์ต์๋์ธ ๊ฒ์ ๊ฒฐ๊ณผ: '{query}' ๊ด๋ จ ๋ ธํธ ์์. ์ถ์ธกํ์ง ๋ง๊ณ ๋ ธํธ๊ฐ ์๋ค๊ณ ๋ตํ๋ผ." | |
| lines = [] | |
| for i, r in enumerate(results, 1): | |
| lines.append(f"{i}. [{r['title']}] ({r['path']})\n โณ {r['snippet']}") | |
| return ("์ต์๋์ธ ๋ณผํธ ๊ฒ์ ๊ฒฐ๊ณผ:\n" + "\n".join(lines) + | |
| "\n์ ์ฒด ๋ณธ๋ฌธ์ด ํ์ํ๋ฉด obsidian_read์ path๋ฅผ ๋๊ฒจ ์ฝ์ด๋ผ.") | |
| except Exception as e: | |
| return f"์ต์๋์ธ ๊ฒ์ ์คํจ: {e}. ๊ฒฐ๊ณผ๋ฅผ ๋จ์ ํ์ง ๋ง๋ผ." | |
| def obsidian_read(self, path: str, source: str = None) -> str: | |
| """ | |
| Reads the full body of a single Obsidian note by its vault-relative path (as returned by | |
| obsidian_search). Works in both Local and Cloud (R2 Live) environments. | |
| Args: | |
| path: Vault-relative path, e.g. 'projects/jarvis.md' | |
| source: 'cloud' to read from the R2 cloud vault even while running locally; | |
| omit/None for the active vault (local-first). | |
| """ | |
| try: | |
| import obsidian_router as ov | |
| vault = ( | |
| ov.get_cloud_vault() | |
| if (source or "").strip().lower() == "cloud" | |
| else ov.get_vault() | |
| ) | |
| if vault is None: | |
| return "์ต์๋์ธ ๋ณผํธ๊ฐ ๋นํ์ฑ์ ๋๋ค. ์ด ํ๊ฒฝ์์๋ ์ฌ์ฉํ ์ ์๋ค." | |
| rel, content = vault.read_note(path) | |
| if len(content) > 8000: | |
| content = content[:8000] + "\nโฆ(์ดํ ์๋ต)" | |
| return f"์ต์๋์ธ ๋ ธํธ [{rel}]:\n\n{content}" | |
| except FileNotFoundError as e: | |
| return f"๋ ธํธ ์์: {e}" | |
| except Exception as e: | |
| return f"์ต์๋์ธ ์ฝ๊ธฐ ์คํจ: {e}." | |
| def obsidian_write(self, path: str, content: str, mode: str = "create") -> str: | |
| """ | |
| Creates/updates a markdown note in the Obsidian vault. MUST be actually CALLED (not just | |
| promised in text) whenever the user asks to create/write/save/organize a note, wiki, document | |
| or page in Obsidian. YOU generate the full markdown body and pass it in `content`. For a | |
| multi-page wiki, call repeatedly (one note per call) and link with [[ ]]. Works in both Local | |
| and Cloud (R2 Live) environments. | |
| Args: | |
| path: Vault-relative path (e.g. 'Wiki/ํ๋ฏผ๊ฒ์์ฆ/index.md'); '.md' added if missing | |
| content: Full markdown body you write (headings/[[backlinks]]/#tags), not a placeholder | |
| mode: 'create' (fail if exists) | 'overwrite' | 'append' | |
| """ | |
| try: | |
| import re as _re | |
| import obsidian_router as ov | |
| vault = ov.get_vault() | |
| if vault is None: | |
| return "์ต์๋์ธ ๋ณผํธ๊ฐ ๋นํ์ฑ์ ๋๋ค. ์ ์ฅ๋๋ค๊ณ ๋งํ์ง ๋ง๋ผ." | |
| mode = mode if mode in ("create", "overwrite", "append") else "create" | |
| # [๐ฑ ๊ฒฝ๋ก ์ ์ ] Windows ๊ธ์ง ๋ฌธ์ ์ ๊ฑฐ โ [Errno 22] ๋ฐฉ์ง | |
| # ํด๋ ๊ตฌ๋ถ์(/)๋ ์ ์ง, ๋๋จธ์ง ๊ธ์ง ๋ฌธ์๋ง ์ ๊ฑฐ | |
| def _sanitize_path(p: str) -> str: | |
| # Windows ํ์ผ๋ช ๊ธ์ง ๋ฌธ์: < > : " \ | ? * ๊ทธ๋ฆฌ๊ณ ์ ๋์ฝ๋ ๋ฐ์ดํ๋ฅ | |
| parts = p.replace("\\", "/").split("/") | |
| clean_parts = [] | |
| for part in parts: | |
| part = _re.sub(r'[<>:"|?*\x00-\x1f\u2018\u2019\u201c\u201d\u0060]', "", part) | |
| part = part.strip(". ") # ์๋ค ์ ยท๊ณต๋ฐฑ ์ ๊ฑฐ (Windows ๊ธ์ง) | |
| if part: | |
| clean_parts.append(part) | |
| return "/".join(clean_parts) if clean_parts else "ollama_note" | |
| path = _sanitize_path(path) | |
| if not path.endswith(".md"): | |
| path += ".md" | |
| # [๐ก๏ธ ์ฐ๊ธฐ ๊ท์จ] AI๊ฐ ์๋ก ๋ง๋๋/๋ฎ์ด์ฐ๋ ๋ ธํธ๊ฐ frontmatter ์์ด ๋ค์ด์ค๋ฉด | |
| # ์ถ์ฒ ๋ฉํ๋ฐ์ดํฐ๋ฅผ ์๋์ผ๋ก ์ฐ๋๋ค. ๊ฒ์ ์ฃผ์ ์ '๋ฏธ๊ฒ์ฆ'์ผ๋ก ๋ผ๋ฒจ๋ง๋์ด | |
| # ํ๊ฐ์ด ๋จ์ ์ ๊ทผ๊ฑฐ๋ก ๊ตณ๋ ๊ฒ์ ๋ง๋๋ค. (append/์ด๋ฏธ frontmatter ์๋ ๋ ธํธ๋ ๋ณด์กด) | |
| if mode in ("create", "overwrite") and not (content or "").lstrip().startswith("---"): | |
| from datetime import datetime, timezone | |
| _who = "" | |
| try: | |
| _who = str((self.dynamic_config or {}).get("display_name") or "").strip() | |
| except Exception: | |
| _who = "" | |
| _stamp = ( | |
| "---\n" | |
| "source_type: ai-generated\n" | |
| "verified: false\n" | |
| f"recorded_by: {_who or 'shadow-brain'}\n" | |
| f"recorded_at: {datetime.now(timezone.utc).isoformat()}\n" | |
| "---\n\n" | |
| ) | |
| content = _stamp + (content or "") | |
| saved = vault.write_note(path, content, mode=mode) | |
| return f"์ต์๋์ธ ๋ ธํธ ์ ์ฅ ์๋ฃ ({mode}): [{saved}]" | |
| except FileExistsError as e: | |
| return f"์ด๋ฏธ ์กด์ฌํ๋ ๋ ธํธ์ ๋๋ค: {e}. overwrite/append ์ค ํ๋๋ก ๋ค์ ์๋ํ๋ผ." | |
| except Exception as e: | |
| return f"์ต์๋์ธ ์ ์ฅ ์คํจ: {e}. ์ ์ฅ๋๋ค๊ณ ๋งํ์ง ๋ง๋ผ." | |
| def project_graph_query(self, query: str, mode: str = "depends_on") -> str: | |
| """ | |
| Queries the Graphify knowledge graph (project map) of the code and documentation structure. | |
| Helps understand file dependencies, class methods, and how documents relate to code, | |
| saving token usage by targeting specific files. | |
| Args: | |
| query: Name of the file, module, or class to query (e.g., 'web_server.py', 'CodingToolkit') | |
| mode: 'depends_on' (import relations) | 'details' (metadata/methods) | 'all' (summary of graph) | |
| """ | |
| try: | |
| import json | |
| from pathlib import Path | |
| # 1๋จ๊ณ: ๋ก์ปฌ static/graph/graph.json ๊ฒฝ๋ก | |
| core_dir = Path(__file__).parent.parent | |
| graph_path = core_dir / "static" / "graph" / "graph.json" | |
| graph_data = None | |
| # 2๋จ๊ณ: ๋ก์ปฌ์ ์๊ฑฐ๋ ํด๋ผ์ฐ๋ ํ๊ฒฝ์ธ ๊ฒฝ์ฐ R2 Live์์ ๋ก๋ ์๋ | |
| import obsidian_router as ov | |
| vault_type = ov.get_vault_type() | |
| if vault_type == "cloud" or not graph_path.exists(): | |
| try: | |
| from cloud_tool import R2_ACCOUNTS, _get_bw_credential | |
| import boto3 | |
| from botocore.config import Config | |
| live_cfg = R2_ACCOUNTS.get("live") | |
| if live_cfg: | |
| account_id = _get_bw_credential(live_cfg["account_id"]) | |
| access_key = _get_bw_credential(live_cfg["access_key_id"]) | |
| secret_key = _get_bw_credential(live_cfg["secret_access_key"]) | |
| bucket_name = _get_bw_credential(live_cfg.get("bucket_name", "taemingames")) | |
| if all([account_id, access_key, secret_key]): | |
| s3 = boto3.client( | |
| "s3", | |
| endpoint_url=f"https://{account_id}.r2.cloudflarestorage.com", | |
| aws_access_key_id=access_key, | |
| aws_secret_access_key=secret_key, | |
| config=Config(signature_version="s3v4") | |
| ) | |
| response = s3.get_object(Bucket=bucket_name, Key="cloud-obsidian/graph/graph.json") | |
| graph_data = json.loads(response["Body"].read().decode("utf-8")) | |
| except Exception as r2_err: | |
| logger.warning(f"Failed to load graph from R2: {r2_err}") | |
| # ๋ก์ปฌ ๊ฒฝ๋ก ๋ฐฑ์ ๋ก๋ | |
| if not graph_data and graph_path.exists(): | |
| with open(graph_path, "r", encoding="utf-8") as f: | |
| graph_data = json.load(f) | |
| if not graph_data: | |
| return "์ง์ ๊ทธ๋ํ ๋ฐ์ดํฐ(graph.json)๋ฅผ ๋ก๋ํ ์ ์์ต๋๋ค. scripts/build_graph.py๋ฅผ ๋จผ์ ๋น๋ํ์ญ์์ค." | |
| nodes = graph_data.get("nodes", []) | |
| links = graph_data.get("links", []) | |
| if mode == "all": | |
| types = {} | |
| for n in nodes: | |
| t = n.get("type", "unknown") | |
| types[t] = types.get(t, 0) + 1 | |
| type_summary = ", ".join([f"{k}: {v}๊ฐ" for k, v in types.items()]) | |
| return f"๐ฑ ์ ๊ตญ ์ง์ ๊ทธ๋ํ ์ ์ฒด ์์ฝ:\n- ์ด ๋ ธ๋: {len(nodes)}๊ฐ ({type_summary})\n- ์ด ์ฐ๊ฒฐ(์์กด์ฑ): {len(links)}๊ฐ" | |
| target = query.strip() | |
| found_node = None | |
| for n in nodes: | |
| if n["id"] == target or n["label"] == target or target in n["id"]: | |
| found_node = n | |
| break | |
| if not found_node: | |
| return f"์ง์ ๊ทธ๋ํ์์ '{target}'์ ํด๋นํ๋ ๋ ธ๋๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค. (๋ชจ๋๋ช /ํด๋์ค๋ช ์คํ ํ์ธ ํ์)" | |
| node_id = found_node["id"] | |
| if mode == "details": | |
| details_str = json.dumps(found_node.get("details", {}), ensure_ascii=False, indent=2) | |
| return f"๐ ๋ ธ๋ ์์ธ ์ ๋ณด [{found_node['label']} ({found_node['type']})]:\nID: {node_id}\n์์ธ:\n{details_str}" | |
| incoming = [] | |
| outgoing = [] | |
| for link in links: | |
| src = link["source"] | |
| tgt = link["target"] | |
| if isinstance(src, dict): src = src.get("id") | |
| if isinstance(tgt, dict): tgt = tgt.get("id") | |
| if src == node_id: | |
| outgoing.append(f"-> [{tgt}] ({link.get('type', 'dependency')})") | |
| elif tgt == node_id: | |
| incoming.append(f"<- [{src}] ({link.get('type', 'dependency')})") | |
| out_str = "\n".join(outgoing) if outgoing else "์์" | |
| in_str = "\n".join(incoming) if incoming else "์์" | |
| return f"๐ฑ ์ง์ ๊ทธ๋ํ ์์กด ๊ด๊ณ [{found_node['label']} ({found_node['type']})]:\n\n[์ถ๋ ฅ ์์กด์ฑ (๋ด๊ฐ ์ฐธ์กฐํ๋ ๊ฒ)]\n{out_str}\n\n[์ ๋ ฅ ์์กด์ฑ (๋๋ฅผ ์ฐธ์กฐํ๋ ๊ฒ)]\n{in_str}" | |
| except Exception as e: | |
| return f"์ง์ ๊ทธ๋ํ ์ฟผ๋ฆฌ ์ค ์ค๋ฅ ๋ฐ์: {e}" | |
| def review_knowledge_proposals(self) -> str: | |
| """ | |
| [Curator] Lists pending knowledge proposals from other guardians awaiting your review. | |
| You are the Imperial Knowledge librarian: verify each proposal with observation tools | |
| (check_port/find_process) before approving via judge_knowledge_proposal. | |
| """ | |
| try: | |
| aegis_url, token = self._resolve_aegis() | |
| if not aegis_url: | |
| return "์ ์ ๋ชฉ๋ก ์กฐํ ๋ถ๊ฐ: Aegis URL ๋ฏธ์ค์ ." | |
| resp = requests.get(f"{aegis_url}/api/knowledge/propose", | |
| headers={"X-Imperial-Token": token}, timeout=5) | |
| data = resp.json() if resp.status_code == 200 else {} | |
| results = data.get("results", []) | |
| if not results: | |
| return "๋๊ธฐ ์ค์ธ ์ง์ ์ ์ ์์." | |
| lines = [] | |
| for r in results: | |
| lines.append(f"- id={r['id']} [{r.get('topic')}|{r.get('scope')}] {r.get('fact')[:150]} " | |
| f"(์ ์: {r.get('proposed_by')}, ๊ฒ์ฆ๋ฒ: {r.get('verify_hint') or '์์'})") | |
| return ("๋๊ธฐ ์ค์ธ ์ง์ ์ ์ ๋ชฉ๋ก:\n" + "\n".join(lines) + | |
| "\n๊ท์น: ๊ฐ ์ ์์ ๊ด์ธก ๋๊ตฌ๋ก ์ง์ ๊ฒ์ฆํ ๋ค judge_knowledge_proposal๋ก ์น์ธ/๊ธฐ๊ฐํ๋ผ. " | |
| "๊ฒ์ฆ ๋ถ๊ฐ๋ฅํ ์ ์์ ์น์ธํ์ง ๋ง๋ผ.") | |
| except Exception as e: | |
| return f"์ ์ ๋ชฉ๋ก ์กฐํ ์คํจ: {e}" | |
| def judge_knowledge_proposal(self, proposal_id: str, approve: bool, note: str = "") -> str: | |
| """ | |
| [Curator] Approves or rejects a pending knowledge proposal. ONLY approve after you have | |
| verified the fact yourself (observation tools or trusted source). Approval replaces any | |
| existing approved fact on the same topic. | |
| Args: | |
| proposal_id: The proposal id from review_knowledge_proposals | |
| approve: true to approve (becomes official knowledge), false to reject | |
| note: Verification basis or rejection reason | |
| """ | |
| try: | |
| aegis_url, token = self._resolve_aegis() | |
| if not aegis_url: | |
| return "์ฌ์ฌ ๋ถ๊ฐ: Aegis URL ๋ฏธ์ค์ ." | |
| resp = requests.post(f"{aegis_url}/api/knowledge/review", | |
| json={"id": proposal_id, "approve": bool(approve), "note": note}, | |
| headers={"X-Imperial-Token": token}, timeout=5) | |
| data = resp.json() if resp.status_code == 200 else {} | |
| if data.get("status") == "SUCCESS": | |
| return f"์ฌ์ฌ ์๋ฃ: {data.get('action')} (id={proposal_id})" | |
| return f"์ฌ์ฌ ์คํจ (HTTP {resp.status_code}): {resp.text[:120]}" | |
| except Exception as e: | |
| return f"์ฌ์ฌ ์คํจ: {e}" | |
| def _begin_ask_user(self, args: dict) -> dict: | |
| """๐ ask_user ๋๊ตฌ ์์ โ ๋ชจ๋ ํจ์ begin_ask ์์.""" | |
| return begin_ask(args) | |
| def _store_web_recon(self, func_name: str, args: dict, result) -> None: | |
| """๐ธ๏ธ hugin ์ ์ฐฐ ๊ฒฐ๊ณผ๋ฅผ web_recon ์นดํ ๊ณ ๋ฆฌ ๋ฒกํฐ ๋ฉ๋ชจ๋ฆฌ์ ์ ์ฅ.""" | |
| mem_text = f"Action: {func_name}, Args: {args}, Result: {str(result)[:500]}" | |
| sovereign_memory.memory_write_vector(mem_text, category="web_recon") | |
| def _wait_ask_user(self, ask_id: str, timeout: int): | |
| """๋ต๋ณ ๋๊ธฐ โ ๋ชจ๋ ํจ์ wait_ask ์์.""" | |
| return wait_ask(ask_id, timeout) | |
| def check_port(self, port: int) -> str: | |
| """ | |
| Read-only observation: checks if a local TCP port is listening RIGHT NOW and which | |
| process owns it. MUST be used to verify environment-scoped knowledge (ports, running | |
| services) before asserting it as current fact. Never changes system state. | |
| Args: | |
| port: TCP port number to check (e.g., 18789) | |
| """ | |
| try: | |
| import psutil | |
| port = int(port) | |
| for conn in psutil.net_connections(kind="tcp"): | |
| if conn.laddr and conn.laddr.port == port and conn.status == psutil.CONN_LISTEN: | |
| info = f"ํฌํธ {port}: LISTENING" | |
| if conn.pid: | |
| try: | |
| p = psutil.Process(conn.pid) | |
| cmd = " ".join(p.cmdline())[:300] | |
| info += f" (PID {conn.pid}, ํ๋ก์ธ์ค: {p.name()}, ์ปค๋งจ๋: {cmd})" | |
| except Exception: | |
| info += f" (PID {conn.pid})" | |
| return info + "\nโ ์ง๊ธ ์ด ํ๊ฒฝ์์ ์ง์ ๊ด์ธกํ ์ฌ์ค์ด๋ฏ๋ก ๋จ์ ํด๋ ๋๋ค." | |
| return (f"ํฌํธ {port}: ํ์ฌ ์ด ํ๊ฒฝ์์ ์์ ๋๊ธฐ ์ค์ธ ํ๋ก์ธ์ค ์์. " | |
| "ํด๋น ์๋น์ค๊ฐ ๊บผ์ ธ ์๊ฑฐ๋ ๋ค๋ฅธ ํฌํธ๋ฅผ ์ด๋ค. ์ผ์ ธ ์๋ค๊ณ ๋จ์ ํ์ง ๋ง๋ผ.") | |
| except Exception as e: | |
| return f"ํฌํธ ๊ด์ธก ์คํจ: {e}. ๊ด์ธก์ด ์ ๋์ผ๋ฏ๋ก ์ํ๋ฅผ ๋จ์ ํ์ง ๋ง๋ผ." | |
| def find_process(self, keyword: str, limit: int = 5) -> str: | |
| """ | |
| Read-only observation: finds running processes whose name or command line contains the | |
| keyword, and shows their command lines (reveals actual config paths in use). MUST be | |
| used to verify which config/install a running service actually uses. Never changes state. | |
| Args: | |
| keyword: Substring to match in process name or command line (e.g., 'openclaw') | |
| limit: Max processes to return (default 5) | |
| """ | |
| try: | |
| import psutil | |
| kw = str(keyword or "").lower() | |
| if len(kw) < 2: | |
| return "๊ด์ธก ์คํจ: ํค์๋๋ 2์ ์ด์์ด์ด์ผ ํ๋ค." | |
| hits = [] | |
| for p in psutil.process_iter(["pid", "name", "cmdline"]): | |
| try: | |
| name = (p.info.get("name") or "").lower() | |
| cmd = " ".join(p.info.get("cmdline") or []) | |
| if kw in name or kw in cmd.lower(): | |
| hits.append(f"- PID {p.info['pid']} [{p.info.get('name')}] {cmd[:300]}") | |
| if len(hits) >= int(limit or 5): | |
| break | |
| except (psutil.NoSuchProcess, psutil.AccessDenied): | |
| continue | |
| if not hits: | |
| return (f"'{keyword}' ๊ด๋ จ ์คํ ์ค ํ๋ก์ธ์ค ์์ (์ง๊ธ ์ด ํ๊ฒฝ ๊ธฐ์ค). " | |
| "์คํ ์ค์ด๋ผ๊ณ ๋จ์ ํ์ง ๋ง๋ผ.") | |
| return (f"'{keyword}' ์คํ ์ค ํ๋ก์ธ์ค (์ง๊ธ ์ด ํ๊ฒฝ์์ ์ง์ ๊ด์ธก):\n" + "\n".join(hits) + | |
| "\nโ ์ปค๋งจ๋๋ผ์ธ์ ๋ณด์ด๋ ๊ฒฝ๋ก๊ฐ ์ค์ ์ฌ์ฉ ์ค์ธ ์ค์ /์ค์น ๊ฒฝ๋ก๋ค.") | |
| except Exception as e: | |
| return f"ํ๋ก์ธ์ค ๊ด์ธก ์คํจ: {e}. ๊ด์ธก์ด ์ ๋์ผ๋ฏ๋ก ์ํ๋ฅผ ๋จ์ ํ์ง ๋ง๋ผ." | |
| def recall_memory(self, query: str, limit: int = 5) -> str: | |
| """ | |
| Searches the user's long-term conversation memory (Aegis vector store). MUST be used | |
| before answering any question about past conversations, user preferences, or shared history. | |
| If this returns no results, honestly say you don't remember instead of guessing. | |
| Args: | |
| query: What to search for in memory (e.g., '์์ผ', '์ข์ํ๋ ์์', '์ด์ ํ ์ฝ์') | |
| limit: Max number of memories to return (default 5) | |
| """ | |
| try: | |
| ctx = getattr(self, "_request_ctx", None) | |
| aegis_url = getattr(ctx, "aegis_url", None) if ctx else None | |
| token = getattr(ctx, "token", None) if ctx else None | |
| session_id = getattr(ctx, "session_id", None) if ctx else None | |
| search_context = session_id if session_id and session_id != "GLOBAL" else None | |
| results = sovereign_memory.memory_search_vector( | |
| query, limit=int(limit or 5), context_id=search_context, | |
| aegis_url=aegis_url, token=token, | |
| ) | |
| if results is None: | |
| # ๐ง [์ ์ง ๊ฒ์ดํธ] ์๋ฒ ๋ฉ ์ฅ์ โ "๊ธฐ์ต ์์"๊ณผ ๊ตฌ๋ถํด ์ฅ์ ๋ฅผ ๊ณ ์ง | |
| notice = sovereign_memory.memory_health_notice() or "๊ธฐ์ต ์์คํ ์ฅ์ " | |
| return ( | |
| f"๊ธฐ์ต ๊ฒ์ ๋ถ๋ฅ: {notice} " | |
| "๊ท์น: '๊ธฐ์ต์ ์๋ค'๊ณ ๋จ์ ํ์ง ๋ง๊ณ , ํ์ฌ ๊ธฐ์ต ๊ธฐ๋ฅ์ ์ฅ์ ๊ฐ ์์ด " | |
| "์ ์ฅ๋ ๊ฒ์๋ ๋์ง ์๋ ์ํ์์ ์์งํ๊ฒ ์๋ ค๋ผ. " | |
| "์ ๋ '๊ธฐ์ตํ๊ฒ ๋ค/์ ์ฅํ๋ค'๊ณ ์ฝ์ํ์ง ๋ง๋ผ." | |
| ) | |
| if not results: | |
| return "๊ธฐ์ต ์ ์ฅ์ ๊ฒ์ ๊ฒฐ๊ณผ: ๊ด๋ จ ๊ธฐ์ต ์์. ์๋ ์ฒํ์ง ๋ง๊ณ '๊ธฐ์ต์ ์์ด์, ์๋ ค์ฃผ์๋ฉด ๊ธฐ์ตํ ๊ฒ์'๋ผ๊ณ ๋ตํ๋ผ." | |
| lines = [] | |
| for i, r in enumerate(results, 1): | |
| content = str(r.get("content", "")).strip() | |
| if len(content) > 300: | |
| content = content[:300] + "โฆ" | |
| lines.append(f"{i}. {content}") | |
| return "๊ธฐ์ต ์ ์ฅ์ ๊ฒ์ ๊ฒฐ๊ณผ (์ค์ ์ ์ฅ๋ ๊ธฐ์ต):\n" + "\n".join(lines) | |
| except Exception as e: | |
| return f"๊ธฐ์ต ๊ฒ์ ์คํจ: {e}. ๊ฒ์์ด ์ ๋์ผ๋ฏ๋ก ๊ณผ๊ฑฐ ์ฌ์ค์ ๋จ์ ํ์ง ๋ง๋ผ." | |
| def count_history(self, query: str = "", message_type: int = -1) -> str: | |
| """ | |
| Counts real chat history records in the Aegis database. MUST be used for questions like | |
| 'how many times did I say X' or 'when did we first talk'. Never guess numbers. | |
| Args: | |
| query: Text to count occurrences of (e.g., '์๋ '). Empty counts all messages. | |
| message_type: 0 = user messages only, 1 = AI messages only, -1 = all (default) | |
| """ | |
| try: | |
| ctx = getattr(self, "_request_ctx", None) | |
| aegis_url = (getattr(ctx, "aegis_url", None) if ctx else None) or "http://localhost:18701" | |
| token = getattr(ctx, "token", None) if ctx else None | |
| session_id = getattr(ctx, "session_id", None) if ctx else None | |
| params = {} | |
| if query: | |
| params["q"] = query | |
| if session_id and session_id != "GLOBAL": | |
| params["context_id"] = session_id | |
| if message_type in (0, 1): | |
| params["message_type"] = message_type | |
| auth = token or os.environ.get("IMPERIAL_API_TOKEN") or os.environ.get("SHADOWBRAIN_TOKEN") or "" | |
| resp = requests.get( | |
| f"{aegis_url.rstrip('/')}/api/chat/history/stats", | |
| params=params, | |
| headers={"X-Imperial-Token": auth, "Authorization": f"Bearer {auth}"}, | |
| timeout=10, | |
| ) | |
| if resp.status_code != 200: | |
| return f"๊ธฐ๋ก ์ง๊ณ ์คํจ (HTTP {resp.status_code}). ์ง๊ณ๊ฐ ์ ๋์ผ๋ฏ๋ก ์ซ์๋ฅผ ์ง์ด๋ด์ง ๋ง๋ผ." | |
| data = resp.json() | |
| return ( | |
| f"๋ํ ๊ธฐ๋ก DB ์ง๊ณ ๊ฒฐ๊ณผ (์ค์ ๋ฐ์ดํฐ): ์ด {data.get('count', 0)}๊ฑด" | |
| + (f", ๊ฒ์์ด '{query}'" if query else "") | |
| + (f", ์ต์ด {data.get('first_at')}" if data.get('first_at') else "") | |
| + (f", ์ต๊ทผ {data.get('last_at')}" if data.get('last_at') else "") | |
| ) | |
| except Exception as e: | |
| return f"๊ธฐ๋ก ์ง๊ณ ์คํจ: {e}. ์ซ์๋ฅผ ์ง์ด๋ด์ง ๋ง๊ณ ํ์ธ ๋ถ๊ฐ๋ผ๊ณ ๋ตํ๋ผ." | |
| def jarvis_memory(self, action: str, content: str = None, category: str = "general", key: str = None, query: str = None) -> str: | |
| """ | |
| Manages Imperial Permanent Memory (Taemin Empire). | |
| Args: | |
| action: 'write', 'read', 'search', 'list', 'delete' | |
| content: Content to write | |
| category: Memory category (e.g., 'user_pref', 'system_log') | |
| key: Specific key for direct read/write | |
| query: Semantic search query | |
| """ | |
| args = ["--action", action] | |
| if content: args += ["--content", content] | |
| if category: args += ["--category", category] | |
| if key: args += ["--key", key] | |
| if query: args += ["--query", query] | |
| return self._execute_cli("jarvis_memory.py", args) | |
| def jarvis_system(self, target: str = "all") -> str: | |
| """ | |
| Retrieves Imperial System Information (Guardians, Status, etc.). | |
| Args: | |
| target: 'all', 'guardians', 'cpu', 'memory', 'disk', 'network' | |
| """ | |
| return self._execute_cli("jarvis_system.py", ["--target", target]) | |
| def jarvis_skill(self, action: str, name: str = None, content: str = None) -> str: | |
| """ | |
| Manages Imperial Skills (Protocols). | |
| Args: | |
| action: 'list', 'read', 'create' | |
| name: Skill name | |
| content: Skill content (for create) | |
| """ | |
| args = ["--action", action] | |
| if name: args += ["--name", name] | |
| if content: args += ["--content", content] | |
| return self._execute_cli("jarvis_skill.py", args) | |
| def jarvis_soul(self, status: str, result_summary: str) -> str: | |
| """ | |
| Reports high-level task completion or soul-sync status. | |
| Args: | |
| status: 'success', 'failure', 'in_progress' | |
| result_summary: Brief summary of the result | |
| """ | |
| return self._execute_cli("jarvis_soul.py", ["--status", status, "--summary", result_summary]) | |
| def jarvis_gbb_post(self, content: str) -> str: | |
| """ | |
| Posts a message to the Imperial Guardian Bulletin Board (GBB). | |
| Args: | |
| content: The message content to post. | |
| """ | |
| try: | |
| from aegis_link import neural_link | |
| token = getattr(self, "auth_token", None) or os.environ.get("IMPERIAL_API_TOKEN") | |
| aegis_url = getattr(self, "aegis_url", None) | |
| logger.info(f"๐ฑ [GBB Post] Sending announcement via REST: {content[:30]}...") | |
| result_data = neural_link.post_announcement(content, aegis_url=aegis_url, token=token) | |
| if result_data: | |
| msg = f"โ GBB ๊ฒ์ ์ฑ๊ณต (ID: {result_data.get('id', 'unknown')})" | |
| self._afc_tool_results.append(msg) | |
| return msg | |
| else: | |
| return "โ GBB ๊ฒ์ ์คํจ (Aegis Oracle ์๋ต ์์)" | |
| except Exception as e: | |
| logger.error(f"[GBB Post] Error: {e}") | |
| return f"Error: GBB ๊ฒ์ ์คํจ - {str(e)}" | |
| def generate_image(self, prompt: str, model: str = "imagen-4.0-generate-001", aspect_ratio: str = "1:1") -> str: | |
| """ | |
| ๋ง์๋์ ์์ฒญ์ ๋ฐ๋ผ AI ์ด๋ฏธ์ง๋ฅผ ์์ฑํ๊ณ ์ฑํ ์ฐฝ์ ํ์ํ ๋งํฌ๋ค์ด ์ด๋ฏธ์ง URL์ ๋ฐํํฉ๋๋ค. | |
| ์ด๋ฏธ์ง ์์ฑ, ๊ทธ๋ฆผ ๊ทธ๋ฆฌ๊ธฐ, ์ฌ์ง ๋ง๋ค๊ธฐ ๋ฑ์ ์์ฒญ์ ์ฌ์ฉํ์ธ์. | |
| Args: | |
| prompt: ์์ฑํ ์ด๋ฏธ์ง์ ์์ธํ ์๋ฌธ ํ๋กฌํํธ (์: 'cinematic portrait of a dragon knight') | |
| model: ์ฌ์ฉํ ๋ชจ๋ธ ID. ์ ํ์ง: 'imagen-4.0-generate-001'(๊ธฐ๋ณธ), 'nano-banana'(๊ณ ์), 'z_image_base'(๋ก์ปฌ/๊ณ ํ์ง) | |
| aspect_ratio: ์ด๋ฏธ์ง ๋น์จ. '1:1'(์ ์ฌ๊ฐํ), '16:9'(๊ฐ๋ก), '9:16'(์ธ๋ก), '4:3', '3:4' | |
| """ | |
| try: | |
| # [IMPERIAL INTELLIGENCE] ์๋ ๋ชจ๋ธ ์ ํ ๋ก์ง (ํ๋กฌํํธ ํค์๋ ๊ธฐ๋ฐ) | |
| low_prompt = prompt.lower() | |
| if any(k in low_prompt for k in ["๋๋ ธ๋ฐ๋๋", "nanobanana", "nano-banana", "nano banana"]): | |
| model = "nano-banana" | |
| logger.info("[ImageGen] '๋๋ ธ๋ฐ๋๋' ํค์๋ ๊ฐ์ง๋ก ๋ชจ๋ธ์ 'nano-banana'๋ก ์๋ ์ ํํฉ๋๋ค.") | |
| elif any(k in low_prompt for k in ["ํคํ์ด์คํ ์ค", "hephaestus", "๋ก์ปฌ์์ง", "z-image", "z_image"]): | |
| model = "z_image_base" | |
| logger.info("[ImageGen] 'ํคํ์ด์คํ ์ค' ๊ด๋ จ ํค์๋ ๊ฐ์ง๋ก ๋ชจ๋ธ์ 'z_image_base'๋ก ์๋ ์ ํํฉ๋๋ค.") | |
| logger.info(f"[ImageGen] ์ด๋ฏธ์ง ์์ฑ ์์: prompt='{prompt[:60]}', model={model}, ratio={aspect_ratio}") | |
| proj_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| # [๐ฑ Imperial Fix] ์ด๋ฏธ์ง ์์ฑ ์๋ํฌ์ธํธ๋ ๊ฐ์ ํ๋ก์ธ์ค(web_server)์ ๋ฑ๋ก๋ผ ์๋ค. | |
| # ๋ฐ๋ผ์ ๋ก์ปฌ์ 18700, ๋ฐฐํฌ(HF/gunicorn)๋ $PORT(์: 7860)๋ก ์๊ธฐ ์์ ์ ํธ์ถํด์ผ ํ๋ค. | |
| # ๊ณผ๊ฑฐ 18700 ํ๋์ฝ๋ฉ ํ์ ๋ฐฐํฌ ํ๊ฒฝ์์ 'Connection Refused'(ํต์ ์ฐ๊ฒฐ ๊ฑฐ๋ถ) ํ๊ฐ์ด ๋ฐ์ํ๋ค. | |
| default_port = os.environ.get("PORT", "18700") | |
| backend_url = os.environ.get("JARVIS_BACKEND_URL", f"http://127.0.0.1:{default_port}") | |
| resp = requests.post( | |
| f"{backend_url}/api/studio/generate-image", | |
| json={"prompt": prompt, "model_id": model, "aspect_ratio": aspect_ratio, "engine": "gemini"}, | |
| timeout=300, | |
| headers={"Content-Type": "application/json"} | |
| ) | |
| if resp.status_code == 200: | |
| data = resp.json() | |
| filename = data.get("filename", "") | |
| if filename: | |
| # [๐ฑ Imperial Fix] ๋ฐฐํฌ ํ๊ฒฝ์ ๋ก์ปฌ ๋์คํฌ๊ฐ ํ๋ฐ์ฑ์ด๋ฏ๋ก ์๋ํฌ์ธํธ๊ฐ R2 ๊ณต๊ฐ URL์ | |
| # ๋ฐํํ๋ค. ์ ๋ URL(http...)์ด๋ฉด ๊ทธ๋๋ก ์ฐ๊ณ , ์๋๊ฒฝ๋ก๋ฉด backend_url์ ๋ถ์ธ๋ค. | |
| returned_url = data.get("url", "") | |
| if isinstance(returned_url, str) and returned_url.startswith("http"): | |
| image_url = returned_url | |
| else: | |
| image_url = f"{backend_url}/api/studio/images/{filename}" | |
| result = f"โ ์ด๋ฏธ์ง ์์ฑ ์๋ฃ!\n\n" | |
| self._afc_tool_results.append(result) | |
| logger.info(f"[ImageGen] ์ฑ๊ณต: {filename} โ {image_url}") | |
| return result | |
| result = f"โ ๏ธ [ImageGen] ์ด๋ฏธ์ง ์์ฑ ์ฑ๊ณตํ์ง๋ง ํ์ผ๋ช ์ ๋ฐ์ง ๋ชปํ์ต๋๋ค. Response: {data}" | |
| self._afc_tool_results.append(result) | |
| return result | |
| else: | |
| err = resp.json().get('error', resp.text[:200]) | |
| result = f"โ [ImageGen] ์ด๋ฏธ์ง ์์ฑ ์คํจ (HTTP {resp.status_code}): {err}" | |
| self._afc_tool_results.append(result) | |
| return result | |
| except requests.exceptions.Timeout: | |
| result = "โ [ImageGen] ์ด๋ฏธ์ง ์์ฑ ํ์์์ (5๋ถ ์ด๊ณผ). ๋ ์งง์ ํ๋กฌํํธ๋ฅผ ์ฌ์ฉํ๊ฑฐ๋ ๋ค์ ์๋ํ์ธ์." | |
| self._afc_tool_results.append(result) | |
| return result | |
| except Exception as e: | |
| logger.error(f"[ImageGen] ์์ธ ์ค๋ฅ: {e}") | |
| result = f"โ [ImageGen] ์ด๋ฏธ์ง ์์ฑ ์ค ์ค๋ฅ ๋ฐ์ - {str(e)}" | |
| self._afc_tool_results.append(result) | |
| return result | |
| def sovereign_type_text(self, text: str, press_enter: bool = True) -> str: | |
| """ | |
| Types text into the Master's active window or focused chat input field. | |
| โ ๏ธ WARNING: DO NOT use this tool to launch applications (e.g. typing 'notepad'). | |
| Use 'sovereign_run_app' for launching system applications instead. | |
| Args: | |
| text: The text to type (chat messages, text fields, etc.). | |
| press_enter: Whether to press 'enter' automatically after typing. Default is True. | |
| """ | |
| try: | |
| from remote_vision_service import remote_type, remote_hotkey | |
| success = remote_type(text) | |
| if success and press_enter: | |
| import time | |
| time.sleep(0.1) # Small delay before pressing enter | |
| remote_hotkey('enter') | |
| result = f"โ Typed text '{text[:20]}...' and pressed ENTER." | |
| else: | |
| result = f"โ Typed text '{text[:20]}...' directly via OS (No Enter)." if success else f"โ Failed to type text via OS." | |
| self._afc_tool_results.append(result) | |
| logger.info(f"[Sovereign Tool] {result}") | |
| return result | |
| except Exception as e: | |
| msg = f"โ Sovereign Type Error: {e}" | |
| self._afc_tool_results.append(msg) | |
| return msg | |
| def sovereign_run_app(self, app_name: str) -> str: | |
| """ | |
| Launches an OS application directly. | |
| Use this for 'notepad', 'calc', 'chrome', 'cmd', etc. | |
| Args: | |
| app_name: The name or path of the application to run. | |
| """ | |
| try: | |
| import platform | |
| from engines.windows import WindowsEngine | |
| from engines.macos import MacOSEngine | |
| engine = WindowsEngine() if platform.system() == "Windows" else MacOSEngine() | |
| success = engine.open_file_or_url(app_name) | |
| result = f"โ Launched application: {app_name}" if success else f"โ Failed to launch application: {app_name}" | |
| self._afc_tool_results.append(result) | |
| logger.info(f"[Sovereign Tool] {result}") | |
| return result | |
| except Exception as e: | |
| msg = f"โ Sovereign RunApp Error: {e}" | |
| self._afc_tool_results.append(msg) | |
| return msg | |
| def sovereign_press_key(self, keys: str) -> str: | |
| """ | |
| Presses a keyboard key directly in the Master's OS. | |
| Use 'enter' to submit a chat message after typing. | |
| Args: | |
| keys: The key to press (e.g., 'enter', 'esc'). | |
| """ | |
| try: | |
| from remote_vision_service import remote_hotkey | |
| raw_keys = [k.strip() for k in keys.split('+')] if '+' in keys else keys | |
| success = remote_hotkey(raw_keys) | |
| result = f"โ Pressed '{keys}' directly via OS." if success else f"โ Failed to press '{keys}' via OS." | |
| self._afc_tool_results.append(result) | |
| logger.info(f"[Sovereign Tool] {result}") | |
| return result | |
| except Exception as e: | |
| msg = f"โ Sovereign Key Error: {e}" | |
| self._afc_tool_results.append(msg) | |
| return msg | |
| def delegate_team_task(self, title: str, assignee_guardian: str, description: str = "", priority: str = "MEDIUM", requester_id: str = None) -> str: | |
| """ | |
| Creates and delegates a task to a specific team member (guardian ์์). | |
| Use this tool when you need to record a formal task for later execution. | |
| Args: | |
| title: Brief task title (e.g., 'Optimize R2 upload pipeline') | |
| assignee_guardian: The guardian name to assign (e.g., 'Acubens', 'Hermes', 'Hydra', 'Atlas') | |
| description: Detailed description of what needs to be done | |
| priority: Task priority - 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL' | |
| requester_id: ๋ช ์์ (์๋ต ์ ํ์ฌ ๋ํ ์๋๋ฐฉ ์ฌ์ฉ) | |
| """ | |
| try: | |
| backend_url = os.environ.get("JARVIS_BACKEND_URL", "http://127.0.0.1:18701") | |
| # [๐ฑ Identity Alignment] | |
| actual_requester = requester_id or self.current_sender or "shadow_brain" | |
| payload = { | |
| "task_content": f"[{title}] {description}", | |
| "executor_id": assignee_guardian, | |
| "priority": priority, | |
| "delegated_by": f"{actual_requester} (via Shadow Brain)", | |
| } | |
| # [๐ก๏ธ] Use Dynamic Delegated Token for authentication | |
| headers = {"Content-Type": "application/json"} | |
| token_to_use = getattr(self, "auth_token", None) or os.environ.get("IMPERIAL_API_TOKEN") | |
| if not token_to_use: | |
| # [๐ฑ V7.3 Unified Auth] | |
| token_to_use = os.environ.get("IMPERIAL_API_TOKEN") or sovereign_memory.get_secret("SHADOWBRAIN_TOKEN") | |
| if token_to_use: | |
| headers["X-Guardian-Token"] = token_to_use | |
| headers["X-Imperial-Token"] = token_to_use | |
| resp = requests.post( | |
| f"{backend_url}/api/team/tasks", # [๐ฑ Fix] Correct Aegis V3 endpoint | |
| json=payload, | |
| headers=headers, | |
| timeout=30, | |
| ) | |
| if resp.status_code in (200, 201): | |
| data = resp.json() | |
| task_id = data.get("task_id", data.get("id", "unknown")) | |
| result = f"โ ์ ๊ตญ ํ ํ ์คํฌ ๊ธฐ๋ก ์๋ฃ!\nโข ID: {task_id}\nโข ์ ๋ชฉ: {title}\nโข ๋ด๋น: {assignee_guardian}\nโข ์ฐ์ ๋: {priority}" | |
| self._afc_tool_results.append(result) | |
| logger.info(f"[Team Delegation] Task '{title}' registered for {assignee_guardian}") | |
| return result | |
| else: | |
| err = resp.text[:200] | |
| result = f"โ ํ ํ ์คํฌ ํ๋ฌ ์คํจ (HTTP {resp.status_code}): {err}" | |
| self._afc_tool_results.append(result) | |
| return result | |
| except Exception as e: | |
| msg = f"โ Team Delegation Error: {e}" | |
| self._afc_tool_results.append(msg) | |
| logger.error(f"[Team Delegation] {msg}") | |
| return msg | |
| def dispatch_to_hydra(self, mission_goal: str, requester_id: str = None) -> str: | |
| """ | |
| Directly dispatches a mission to Hydra for autonomous parallel planning and execution. | |
| Use this tool when you need an immediate synthesized report from multiple agents (Acubens). | |
| Args: | |
| mission_goal: The high-level objective for Hydra to analyze and decompose. | |
| requester_id: ์์ฒญ์ ๋ช ์ (์๋ต ์ '๋ง์๋' ๋๋ ํ์ฌ ๋ํ ์๋) | |
| """ | |
| try: | |
| logger.info(f"[๐ฑ Hydra Dispatch] Initializing mission: {mission_goal}") | |
| # [๐ฑ Identity Alignment] | |
| actual_requester = requester_id or self.current_sender or "shadow_brain" | |
| hydra_url = os.environ.get("HYDRA_API_URL", "http://localhost:8002/hydra/dispatch") | |
| # [๐ก๏ธ] Propagate delegated token to Hydra | |
| headers = {"Content-Type": "application/json"} | |
| token_to_use = getattr(self, "auth_token", None) or os.environ.get("IMPERIAL_API_TOKEN") | |
| if token_to_use: | |
| headers["X-Imperial-Token"] = token_to_use | |
| payload = { | |
| "goal": mission_goal, | |
| "requester_id": actual_requester | |
| } | |
| resp = requests.post(hydra_url, json=payload, headers=headers, timeout=300) # 5๋ถ ํ์์์ | |
| if resp.status_code == 200: | |
| data = resp.json() | |
| report = data.get("synthesis", "๋ณด๊ณ ์ ์์ฑ ์คํจ") | |
| result = f"๐ฒ **[ํ๋๋ผ ์์จ ๊ธฐํ ๋ณด๊ณ ์]**\n\n{report}" | |
| self._afc_tool_results.append(result) | |
| return result | |
| elif resp.status_code == 503: | |
| return "โ ๏ธ [ํ๋๋ผ ์ฐ๊ฒฐ ์คํจ] ํ๋๋ผ ์๋ฒ๊ฐ ์๋ตํ์ง ์์ต๋๋ค. ์ํธ์ ํ๋๋ผ๋ฅผ ๋จผ์ ๊ธฐ๋(RunHydra.bat)ํด ์ฃผ์ญ์์ค." | |
| else: | |
| err = resp.text[:300] | |
| return f"โ [ํ๋๋ผ ์ค๋ฅ] HTTP {resp.status_code}: {err}" | |
| except requests.exceptions.ConnectionError: | |
| return "โ ๏ธ [ํ๋๋ผ ์คํ๋ผ์ธ] ํ๋๋ผ ์๋ฒ(Port 8002)์ ์ฐ๊ฒฐํ ์ ์์ต๋๋ค. ์ํธ์ ํ๋๋ผ๋ฅผ ๊นจ์์ฃผ์ญ์์ค." | |
| except Exception as e: | |
| logger.error(f"[Hydra Dispatch] Exception: {e}") | |
| return f"โ [Hydra Dispatch] ๋ด๋ถ ์ค๋ฅ ๋ฐ์: {str(e)}" | |
| def _emit_token_usage(self, provider: str, model_name: str, usage): | |
| """[๐ช Imperial Token Ledger] API ํธ์ถ 1๊ฑด์ ํ ํฐ ์ฌ์ฉ๋์ Aegis์ ๋น๋๊ธฐ ์ ์ฌ. | |
| usage: OpenAI ํธํ usage dict ๋๋ ์ ๊ทํ๋ dict. | |
| ์ง์ ํค(์๋ ์ธ์): | |
| - prompt_tokens / input_tokens / prompt_token_count | |
| - completion_tokens / output_tokens / candidates_token_count | |
| - total_tokens / total_token_count | |
| - prompt_tokens_details.cached_tokens / cache_read_input_tokens / cached_content_token_count | |
| ์คํธ๋ฆฌ๋ฐ ์๋ต์์ ๊ณต๊ธ์๊ฐ usage๋ฅผ ๋ณด๊ณ ํ์ง ์์ผ๋ฉด ์กฐ์ฉํ ๋ฌด์ํ๋ค(๋ ธ์ด์ฆ ๋ฐฉ์ง). | |
| """ | |
| try: | |
| if not usage: | |
| return | |
| if not isinstance(usage, dict): | |
| # google genai usage_metadata ๋ฑ ๊ฐ์ฒด โ dict ์ ๊ทํ | |
| usage = { | |
| "prompt_token_count": getattr(usage, "prompt_token_count", None), | |
| "candidates_token_count": getattr(usage, "candidates_token_count", None), | |
| "total_token_count": getattr(usage, "total_token_count", None), | |
| "cached_content_token_count": getattr(usage, "cached_content_token_count", None), | |
| } | |
| def _pick(*keys): | |
| for k in keys: | |
| v = usage.get(k) | |
| if v is not None: | |
| try: | |
| return int(v) | |
| except (TypeError, ValueError): | |
| continue | |
| return 0 | |
| prompt_t = _pick("prompt_tokens", "input_tokens", "prompt_token_count") | |
| completion_t = _pick("completion_tokens", "output_tokens", "candidates_token_count") | |
| total_t = _pick("total_tokens", "total_token_count") or (prompt_t + completion_t) | |
| cached_t = 0 | |
| details = usage.get("prompt_tokens_details") | |
| if isinstance(details, dict): | |
| cached_t = int(details.get("cached_tokens") or 0) | |
| if not cached_t: | |
| cached_t = _pick("cache_read_input_tokens", "cached_content_token_count") | |
| if total_t <= 0: | |
| return | |
| session_id = getattr(self._request_ctx, "session_id", None) or "GLOBAL" | |
| token = (getattr(self._request_ctx, "token", None) | |
| or getattr(self, "auth_token", None) | |
| or os.environ.get("IMPERIAL_API_TOKEN", "")) | |
| aegis_url = getattr(self._request_ctx, "aegis_url", None) or "http://localhost:18701" | |
| aegis_url = aegis_url.rstrip("/") | |
| provider_key = str(provider or "").strip().lower() | |
| model_key = str(model_name or "").strip().lower() | |
| is_billable = ( | |
| provider_key not in {"ollama", "atlas", "local"} | |
| and not model_key.endswith(":free") | |
| ) | |
| payload = { | |
| "context_id": session_id, | |
| "usage_type": "chat", | |
| "billable": is_billable, | |
| "provider": provider, | |
| "model": model_name, | |
| "prompt_tokens": prompt_t, | |
| "completion_tokens": completion_t, | |
| "total_tokens": total_t, | |
| "cached_tokens": cached_t, | |
| } | |
| def _post(): | |
| try: | |
| headers = {"Content-Type": "application/json"} | |
| if token: | |
| headers["Authorization"] = f"Bearer {token}" | |
| headers["X-Imperial-Token"] = token | |
| r = requests.post( | |
| f"{aegis_url}/api/token-usage", | |
| json=payload, headers=headers, timeout=10, | |
| ) | |
| if r.status_code != 200: | |
| logger.warning( | |
| f"[๐ช TokenLedger] ์ ์ฌ ์คํจ HTTP {r.status_code}: " | |
| f"{r.text[:200]} (url={aegis_url}/api/token-usage, has_token={bool(token)})" | |
| ) | |
| else: | |
| logger.info(f"[๐ช TokenLedger] ์ ์ฌ ์ฑ๊ณต: {r.text[:120]}") | |
| except Exception as _e: | |
| logger.warning(f"[๐ช TokenLedger] ์ ์ฌ ์์ธ(๋ฌด์): {_e}") | |
| threading.Thread(target=_post, daemon=True).start() | |
| logger.info( | |
| f"[๐ช TokenLedger] {provider}/{model_name} " | |
| f"in={prompt_t} out={completion_t} total={total_t} cached={cached_t} ctx={session_id[:16]}" | |
| ) | |
| except Exception as e: | |
| logger.debug(f"[TokenLedger] emit ์คํจ(๋ฌด์): {e}") | |
| def _resolve_shadow_brain_user_id(self, aegis_url=None, token=None) -> str | None: | |
| """[๐ฑ Stateless Oracle] Aegis API๋ฅผ ํตํด Shadow Brain์ user_id๋ฅผ ํ์ธํฉ๋๋ค.""" | |
| try: | |
| from aegis_link import neural_link | |
| shadow_persona_id = "a8e1a1af-f28b-47ab-b371-193082508638" | |
| p_data = neural_link.get_guardian_identity(shadow_persona_id, aegis_url=aegis_url, token=token) | |
| if p_data: | |
| owner_id = p_data.get("owner_user_id") | |
| if owner_id: | |
| logger.info(f"[GBB] Shadow Brain user_id resolved: {owner_id[:8]}...") | |
| return owner_id | |
| # Fallback to persona ID itself if owner lookup fails | |
| return shadow_persona_id | |
| except Exception as e: | |
| logger.error(f"[GBB] Shadow Brain identity resolution failed: {e}") | |
| return "a8e1a1af-f28b-47ab-b371-193082508638" | |
| def _get_vsc_machine_id(self) -> str: | |
| """Generates a stable, pseudo-random hex string for VS Code identity mimicry.""" | |
| import socket | |
| try: | |
| device_seed = socket.gethostname() or "Imperial-Sentinel-Core" | |
| except: | |
| device_seed = "Imperial-Sentinel-Core" | |
| return hashlib.sha256(device_seed.encode()).hexdigest() | |
| def _get_copilot_token(self): | |
| """Exchanges GITHUB_TOKEN for a Copilot session token or reads local native session.""" | |
| if self._copilot_token and self._copilot_expires > time.time() + 60: | |
| return self._copilot_token, self._copilot_url | |
| # [IMPERIAL EXTENSION] Read directly from Shared Relay, Aizen, or Gilgamesh credentials | |
| base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "docker_data")) | |
| token_paths = [ | |
| os.path.join(base_dir, "shared-relay", "credentials", "github-copilot.token.json"), | |
| os.path.join(base_dir, "openclaw", "aizen", "credentials", "github-copilot.token.json"), | |
| os.path.join(base_dir, "openclaw", "gilgamesh", "credentials", "github-copilot.token.json") | |
| ] | |
| for tp in token_paths: | |
| if os.path.exists(tp): | |
| try: | |
| with open(tp, 'r', encoding='utf-8') as f: | |
| data = json.load(f) | |
| tok = data.get("token") | |
| exp = data.get("expiresAt", 0) | |
| if exp > 10000000000: | |
| exp /= 1000 | |
| if tok and exp > time.time() + 60: | |
| self._copilot_token = tok | |
| self._copilot_expires = exp | |
| match = re.search(r"(?:^|;)\s*proxy-ep=([^;\s]+)", tok, re.IGNORECASE) | |
| if match: | |
| proxy_ep = match.group(1).strip() | |
| host = proxy_ep.replace("https://", "").replace("http://", "").replace("proxy.", "api.") | |
| self._copilot_url = f"https://{host}" | |
| logger.info(f"Imperial Guardian successfully synchronized Copilot soul from: {os.path.basename(os.path.dirname(os.path.dirname(tp)))}") | |
| return self._copilot_token, self._copilot_url | |
| except Exception as e: | |
| logger.warning(f"Failed to read token from {tp}: {e}") | |
| # [IMPERIAL EXTENSION] 2. Prioritize PROJECT ENV GITHUB_TOKEN or read permanent OAuth from profiles | |
| _project_root_env = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".env")) | |
| from dotenv import dotenv_values | |
| env_dict = dotenv_values(_project_root_env) | |
| oauth_token = os.environ.get("GITHUB_TOKEN") or env_dict.get("GITHUB_TOKEN") | |
| if not oauth_token or "your_github" in oauth_token: | |
| profile_paths = [ | |
| os.path.join(base_dir, "shared-relay", "auth-profiles.json"), | |
| os.path.join(os.path.expanduser("~"), ".openclaw", "auth-profiles.json") | |
| ] | |
| for pp in profile_paths: | |
| if os.path.exists(pp): | |
| try: | |
| with open(pp, 'r', encoding='utf-8') as f: | |
| p_data = json.load(f) | |
| oauth = p_data.get("profiles", {}).get("github-copilot:github", {}).get("token") | |
| if oauth and (oauth.startswith("ghu_") or oauth.startswith("gho_")): | |
| oauth_token = oauth | |
| logger.info(f"Imperial Guardian acquired permanent OAuth token from profile: {pp}") | |
| break | |
| except Exception: | |
| pass | |
| if not oauth_token: | |
| logger.info("No valid local Copilot session or profile found. Falling back to GITHUB_TOKEN api...") | |
| github_token = os.environ.get("GITHUB_TOKEN") | |
| if not github_token: | |
| return None, None | |
| oauth_token = github_token | |
| try: | |
| res = requests.get("https://api.github.com/copilot_internal/v2/token", headers={ | |
| "Authorization": f"Bearer {oauth_token}", | |
| "Accept": "application/json", | |
| "Editor-Version": "vscode/1.93.0", | |
| "Editor-Plugin-Version": "copilot/1.222.0", | |
| "User-Agent": "GithubCopilot/1.222.0" | |
| }, timeout=10) | |
| res.raise_for_status() | |
| data = res.json() | |
| self._copilot_token = data.get("token") | |
| self._copilot_expires = data.get("expires_at", 0) | |
| if self._copilot_expires > 10000000000: # ms to s | |
| self._copilot_expires /= 1000 | |
| match = re.search(r"(?:^|;)\s*proxy-ep=([^;\s]+)", self._copilot_token, re.IGNORECASE) | |
| if match: | |
| proxy_ep = match.group(1).strip() | |
| host = proxy_ep.replace("https://", "").replace("http://", "").replace("proxy.", "api.") | |
| self._copilot_url = f"https://{host}" | |
| logger.info("Successfully minted new Copilot Session Token using OAuth Device Token.") | |
| return self._copilot_token, self._copilot_url | |
| except Exception as e: | |
| logger.error(f"Copilot Token Error: {e}") | |
| return None, None | |
| def _get_client(self, aegis_url=None, token=None): | |
| """๐ฑ [Imperial] ์์ฒญ ์ปจํ ์คํธ์ ๋ง๋ genai Client๋ฅผ ๋ฐํํฉ๋๋ค.""" | |
| # 1. ํน์ Aegis URL์ด ์ฃผ์ ๋ ๊ฒฝ์ฐ, ํด๋น ํ๊ฒฝ์ ํค๋ฅผ ์ํ๋ฐ์ | |
| target_key = self.api_key | |
| if aegis_url: | |
| # sovereign_memory๋ฅผ ํตํด ํด๋น Aegis์ ํค๋ฅผ ๊ฐ์ ธ์ด | |
| from sovereign_memory import get_secret | |
| # [๐ก๏ธ] ์๋์ฐ ๋ธ๋ ์ธ์ ์์ฒด ํ ํฐ์ด ์์ผ๋ฉด ์ฐ์ ์ฌ์ฉ (์ธ์ฆ์ฉ) | |
| # brain_service์์ ๋์ด์จ token์ด ์๋ค๋ฉด ๊ทธ๊ฒ์ ์ฌ์ฉ | |
| resolved_key = get_secret("GEMINI_API_KEY", aegis_url=aegis_url, token=token) | |
| if resolved_key and not resolved_key.startswith("Imperial:"): | |
| target_key = resolved_key | |
| if not target_key: | |
| logger.error(f"[Brain Core] โ GEMINI_API_KEY๋ฅผ ํด๊ฒฐํ ์ ์์ต๋๋ค. (Aegis: {aegis_url})") | |
| return None | |
| # 2. ํด๋ผ์ด์ธํธ ์บ์ ํ์ธ ๋ฐ ์์ฑ | |
| if target_key not in self._clients: | |
| if genai: | |
| logger.info(f"[Brain Core] ๐ฐ๏ธ New environment detected. Initializing client for key: {target_key[:8]}...") | |
| self._clients[target_key] = genai.Client(api_key=target_key) | |
| else: | |
| return None | |
| return self._clients[target_key] | |
| def _think_fallback(self, prompt: str, system_instruction: str = None, skip_tools: bool = False, tools: list = None) -> Generator[str, None, None]: | |
| """[ํด๋ฐฑ ๋นํ์ฑํ] ๊ณผ๊ฑฐ์๋ 1์ฐจ ์คํจ ์ ๋ค๋ฅธ ํ๋ก๋ฐ์ด๋(github/gpt-4o ๋ฑ)๋ก ์๋ ์ ํํ์ผ๋, | |
| ์ค์ ์๋ฌ๋ฅผ ๊ฐ๋ ค ๋๋ฒ๊น ์ ๋ฐฉํดํ๋ฏ๋ก ์ ๊ฑฐํจ. ์ด์ ์คํจ๋ฅผ ๊ทธ๋๋ก ๋ ธ์ถํ๋ค.""" | |
| logger.warning( | |
| "[Fallback Disabled] ํ๋ก๋ฐ์ด๋ ์๋ ์ ํ์ด ๋นํ์ฑํ๋์์ต๋๋ค. " | |
| f"(primary={self.primary_provider}/{self.primary_model})" | |
| ) | |
| yield ( | |
| "โ [๋ธ๋ ์ธ ์คํจ] 1์ฐจ ๋ชจ๋ธ์ด ์๋ตํ์ง ๋ชปํ๊ณ , ์๋ ํด๋ฐฑ์ ๋นํ์ฑํ๋์ด ์์ต๋๋ค.\n" | |
| f"(provider={self.primary_provider}, model={self.primary_model})\n" | |
| "ํ๋ก๋ฐ์ด๋/๋ชจ๋ธ/๋คํธ์ํฌ ์ํ๋ฅผ ์ง์ ํ์ธํ์ธ์." | |
| ) | |
| def _get_tools_schema(self, prompt: str = ""): | |
| """Returns the tools in OpenAI-compatible format. | |
| prompt์ด ์ฃผ์ด์ง๋ฉด ์๋๋ณ๋ก ๋๊ตฌ๋ฅผ ํ๋ ์ด์ ํด ๋ชฉ๋ก์ ์๊ฒ ์ ์งํ๋ค(ํค๋ฅด๋ฉ์ค์ฒ๋ผ ๊น๋). | |
| prompt์ด ๋น๋ฉด(๋ ๊ฑฐ์ ํธ์ถ) ์ ๋ ๋ ธ์ถ๋ก ์์ ํ๊ฒ ๋์. | |
| """ | |
| # ๐ MCP ๋๊ตฌ(google-surf ๊ฒ์, filesystem ๋ฑ)๋ OpenAI ๊ฒฝ๋ก์ ๋ ธ์ถ | |
| mcp_tools = [] | |
| if get_mcp_client: | |
| try: | |
| _mc = get_mcp_client() | |
| if _mc: | |
| mcp_tools = _mc.get_openai_tools() | |
| except Exception as _mcp_err: | |
| logger.debug(f"[MCP] OpenAI ์คํค๋ง ๋ณํ ์คํจ(๋ฌด์): {_mcp_err}") | |
| # ๐งน ์๋ ๊ฐ์ง (prompt ์์ผ๋ฉด ์ ๋ ๋ ธ์ถ) | |
| if prompt: | |
| # ๐๏ธ ์ต์๋์ธ ์์ฑ/๊ฒ์ ์๋๋ฉด ์ฝ๋ฉ ๋ชจ๋๋ฅผ ๋๋ค โ ํ์ผ/ํฐ๋ฏธ๋ ๋๊ตฌ๊ฐ ๋ชฉ๋ก์ | |
| # ๋ฒ๋์์ผ ์ฝํ ๋ชจ๋ธ์ด obsidian_write๋ฅผ ๋ชป ๊ณ ๋ฅด๋ ๋ฌธ์ ์ฐจ๋จ. | |
| wants_coding = self._wants_coding(prompt) and not self._wants_obsidian(prompt) | |
| flags = {"hugin": self._wants_hugin(prompt), | |
| "os": self._wants_os_control(prompt), | |
| "delegation": self._wants_delegation(prompt)} | |
| else: | |
| wants_coding = True | |
| flags = {"hugin": True, "os": True, "delegation": True} | |
| coding = list(CODING_TOOL_SCHEMAS) if wants_coding else [] | |
| # ๐ฑ [ํต์ผ] MCP ๋๊ตฌ ํ๋ ์ด์ โ ๋ค์ดํฐ๋ธ ์ค๋ณต ์ ๊ฑฐ + ๋ฌด๊ฑฐ์ด ๊ทธ๋ฃน ์๋ ๊ฒ์ดํธ. | |
| _mcp_flags = { | |
| "hugin": flags.get("hugin", True), | |
| "search": (self._wants_web_search(prompt) if prompt else True), | |
| "coding": wants_coding, | |
| } | |
| mcp_tools = [t for t in mcp_tools | |
| if self._mcp_name_kept(str(t.get("function", {}).get("name", "")), _mcp_flags)] | |
| # ๐งฐ ๋ค์ดํฐ๋ธ ๋๊ตฌ ์คํค๋ง(์๋๋ณ) + ์ง์ whitelist ์ํ (Gemini ๊ฒฝ๋ก์ ๋์ผ ๊ท์น) | |
| native = self._apply_native_whitelist_openai(self.runtime.curated_native_schemas(flags)) | |
| return coding + mcp_tools + native | |
| def _get_client(self, aegis_url=None, token=None): | |
| """๐ฑ [Imperial] ์์ฒญ ์ปจํ ์คํธ์ ๋ง๋ genai Client๋ฅผ ๋ฐํํฉ๋๋ค.""" | |
| # 1. ํน์ Aegis URL์ด ์ฃผ์ ๋ ๊ฒฝ์ฐ, ํด๋น ํ๊ฒฝ์ ํค๋ฅผ ์ํ๋ฐ์ | |
| target_key = self.api_key | |
| if aegis_url: | |
| from sovereign_memory import get_secret | |
| resolved_key = get_secret("GEMINI_API_KEY", aegis_url=aegis_url, token=token) | |
| if resolved_key and not resolved_key.startswith("Imperial:"): | |
| target_key = resolved_key | |
| if not target_key: | |
| logger.error(f"[Brain Core] โ GEMINI_API_KEY๋ฅผ ํด๊ฒฐํ ์ ์์ต๋๋ค. (Aegis: {aegis_url})") | |
| return None | |
| # 2. ํด๋ผ์ด์ธํธ ์บ์ ํ์ธ ๋ฐ ์์ฑ | |
| if target_key not in self._clients: | |
| if genai: | |
| logger.info(f"[Brain Core] ๐ฐ๏ธ New environment detected. Initializing client for key: {target_key[:8]}...") | |
| self._clients[target_key] = genai.Client(api_key=target_key) | |
| else: | |
| return None | |
| return self._clients[target_key] | |
| def _think_copilot( | |
| self, | |
| prompt: str, | |
| model_name: str, | |
| system_instruction: str = None, | |
| show_self_healing_banner: bool = False, | |
| self_healing_provider: str = "github", | |
| skip_tools: bool = False, | |
| tools: list = None | |
| ) -> Generator[str, None, None]: | |
| """Fallback brain using GitHub Copilot API with Tool Use support.""" | |
| try: | |
| actual_model = self._resolve_actual_model("github", model_name) | |
| except ValueError as ve: | |
| yield f"\nโ [Neural Core Error] {str(ve)}" | |
| return | |
| token, base_url = self._get_copilot_token() | |
| if not token: | |
| yield f"[ERROR] Neural Core Failed: GitHub Copilot token unavailable for {actual_model}." | |
| return | |
| messages = [{"role": "user", "content": prompt}] | |
| if system_instruction: | |
| messages.insert(0, {"role": "system", "content": system_instruction}) | |
| tools = self._get_tools_schema(prompt) if not skip_tools else None | |
| try: | |
| logger.info(f"Imperial Brain Switching to Fallback Core: {actual_model} (GitHub Copilot) with Tool Support") | |
| # OpenAI / Copilot Tool-Calling Loop | |
| max_iterations = self.dynamic_config.get("max_iterations", 15) | |
| # ๐ก๏ธ [Tool Runtime] ๋๊ตฌ ๋ฃจํ ์ํ ์ผ์ํ (3๊ฒฝ๋ก ๊ณตํต) | |
| ctx = RuntimeCtx(prompt, interactive=True, | |
| debug=_is_debug_mode(), display_name="Imperial") | |
| for _ in range(max_iterations): | |
| json_data = { | |
| "messages": messages, | |
| "model": actual_model, | |
| "stream": True, | |
| "stream_options": {"include_usage": True}, # ๐ช ํ ํฐ ์ฌ์ฉ๋ ์ง๊ณ | |
| } | |
| if tools and not ctx.force_no_tools: | |
| json_data["tools"] = tools | |
| json_data["tool_choice"] = "auto" | |
| res = requests.post(f"{base_url}/chat/completions", headers={ | |
| "Authorization": f"Bearer {token}", | |
| "Content-Type": "application/json", | |
| "Editor-Version": "vscode/1.102.0", | |
| "Editor-Plugin-Version": "copilot/1.300.0", | |
| "User-Agent": "GithubCopilot/1.300.0", | |
| "VSC-Machine-Id": self._get_vsc_machine_id(), | |
| "VSC-Session-Id": str(uuid.uuid4()), | |
| "Accept": "application/json" | |
| }, json=json_data, stream=True, timeout=120) | |
| if not res.ok: | |
| error_detail = res.text | |
| yield f"\nโ [Neural Core Error] GitHub Copilot ์ด ์์ฒญ์ ๊ฑฐ์ ํ์ต๋๋ค ({res.status_code}).\n์์ธ ์ฌ์ : {error_detail}" | |
| return | |
| full_content = "" | |
| tool_calls = [] | |
| first_yield = True | |
| _usage_acc = None # ๐ช ํ ํฐ ์ฌ์ฉ๋(์ต์ข ์ฒญํฌ) | |
| for line in res.iter_lines(): | |
| if line: | |
| l_str = line.decode('utf-8') | |
| if l_str.startswith("data: "): | |
| data_raw = l_str[6:] | |
| if data_raw.strip() == "[DONE]": break | |
| try: | |
| chunk = json.loads(data_raw) | |
| if chunk.get("usage"): | |
| _usage_acc = chunk["usage"] | |
| if not chunk.get("choices"): | |
| continue | |
| delta = chunk["choices"][0].get("delta", {}) | |
| # Handle normal content | |
| content = delta.get("content", "") | |
| if content: | |
| full_content += content | |
| if first_yield: | |
| if show_self_healing_banner: | |
| yield f"[SYSTEM] [Neural Self-Healing: {self_healing_provider.upper()} ({actual_model}) Activated]\n\n" | |
| first_yield = False | |
| if not ctx.suppress_stream: | |
| yield content | |
| # Handle tool calls | |
| tc_delta = delta.get("tool_calls", []) | |
| for tc in tc_delta: | |
| index = tc.get("index", 0) | |
| while len(tool_calls) <= index: | |
| tool_calls.append({"id": "", "type": "function", "function": {"name": "", "arguments": ""}}) | |
| if "id" in tc: tool_calls[index]["id"] += tc["id"] | |
| if "function" in tc: | |
| f = tc["function"] | |
| if "name" in f: tool_calls[index]["function"]["name"] += f["name"] | |
| if "arguments" in f: tool_calls[index]["function"]["arguments"] += f["arguments"] | |
| except: pass | |
| self._emit_token_usage("github", actual_model, _usage_acc) | |
| if not tool_calls: | |
| # ๐ก๏ธ [Tool Runtime] ๋๊ตฌ ์๋ ์๋ต โ ๊ตฌ์ ์ฒ๋ฆฌ ์ผ์ํ | |
| _action = yield from self.runtime.handle_no_tool_calls(full_content, messages, ctx) | |
| if _action == "continue": | |
| continue | |
| break | |
| # ๐ก๏ธ [Tool Runtime] ๋๊ตฌ ํธ์ถ ์ผ๊ด ์คํ (๊ฒ์ฆยท๋ณด์ ยทask ๊ฐ๋ยท์ํท๋ธ๋ ์ด์ปค ๊ณตํต) | |
| yield from self.runtime.run_tool_calls(tool_calls, full_content, messages, ctx) | |
| except Exception as e: | |
| logger.error(f"Copilot Core Error: {e}") | |
| yield f"\nโ [Copilot] Neural Core Malfunction: {str(e)}" | |
| def _assign_atlas_port(self) -> Optional[int]: | |
| """Queries the Atlas Broker for a dynamic port assignment.""" | |
| try: | |
| assign_url = f"{self.atlas_broker_url}/assign?agent=shadow_brain" | |
| logger.info(f"[๐ฑ Atlas] Requesting port assignment from {assign_url}") | |
| resp = requests.get(assign_url, timeout=5) | |
| if resp.status_code == 200: | |
| data = resp.json() | |
| port = data.get("port") | |
| if port: | |
| logger.info(f"[๐ฑ Atlas] Received intelligence port: {port}") | |
| return port | |
| logger.error(f"[๐ฑ Atlas] Broker assignment failed (HTTP {resp.status_code}): {resp.text}") | |
| except Exception as e: | |
| logger.error(f"[๐ฑ Atlas] Broker connection error: {e}") | |
| return None | |
| def _think_atlas( | |
| self, | |
| prompt: str, | |
| model_name: str, | |
| system_instruction: str = None, | |
| skip_tools: bool = False, | |
| tools: list = None | |
| ) -> Generator[str, None, None]: | |
| """Local Brain using Atlas Orchestrator (llama-server) with Tool Support.""" | |
| port = self._assign_atlas_port() | |
| if not port: | |
| yield f"[ERROR] ๐ฑ Atlas Broker Neural Assignment Failed. Check if RunAtlas.bat is active." | |
| return | |
| # ๐ฑ ์ํ๋ผ์ค ์ฌ๋ น๋ถ(Broker)๋ฅผ ๊ด๋ฌธ์ผ๋ก ์ฌ์ฉํ์ฌ ์ ๋ชจ๋ ํธํ์ฑ ํ๋ณด | |
| base_url = f"{self.atlas_broker_url}/v1" | |
| messages = [{"role": "user", "content": prompt}] | |
| if system_instruction: | |
| messages.insert(0, {"role": "system", "content": system_instruction}) | |
| # Atlas currently uses standard OpenAI-compatible tool schema if supported by the model | |
| atlas_tools = self._get_tools_schema(prompt) if not skip_tools else None | |
| try: | |
| logger.info(f"Imperial Brain engaging LOCAL ATLAS Shield ({model_name}) on Port {port}") | |
| # Streaming Loop (similar to Copilot/OpenAI but local) | |
| max_iterations = self.dynamic_config.get("max_iterations", 15) | |
| # ๐ก๏ธ [Tool Runtime] ๋๊ตฌ ๋ฃจํ ์ํ ์ผ์ํ (3๊ฒฝ๋ก ๊ณตํต) | |
| ctx = RuntimeCtx(prompt, interactive=True, | |
| debug=_is_debug_mode(), display_name="Atlas") | |
| for _ in range(max_iterations): | |
| json_data = { | |
| "messages": messages, | |
| "model": model_name, | |
| "stream": True, | |
| "temperature": 0.7, | |
| "stream_options": {"include_usage": True}, # ๐ช ํ ํฐ ์ฌ์ฉ๋ ์ง๊ณ | |
| } | |
| if atlas_tools and not ctx.force_no_tools: | |
| json_data["tools"] = atlas_tools | |
| json_data["tool_choice"] = "auto" | |
| res = requests.post(f"{base_url}/chat/completions", json=json_data, stream=True, timeout=120) | |
| res.raise_for_status() | |
| full_content = "" | |
| tool_calls = [] | |
| _usage_acc = None # ๐ช ํ ํฐ ์ฌ์ฉ๋(์ต์ข ์ฒญํฌ) | |
| for line in res.iter_lines(): | |
| if line: | |
| l_str = line.decode('utf-8') | |
| if l_str.startswith("data: "): | |
| data_raw = l_str[6:] | |
| if data_raw.strip() == "[DONE]": break | |
| try: | |
| chunk = json.loads(data_raw) | |
| if chunk.get("usage"): | |
| _usage_acc = chunk["usage"] | |
| if not chunk.get("choices"): | |
| continue | |
| delta = chunk["choices"][0].get("delta", {}) | |
| # Handle normal content | |
| content = delta.get("content", "") | |
| if content: | |
| full_content += content | |
| if not ctx.suppress_stream: | |
| yield content | |
| # Handle tool calls | |
| tc_delta = delta.get("tool_calls", []) | |
| for tc in tc_delta: | |
| index = tc.get("index", 0) | |
| while len(tool_calls) <= index: | |
| tool_calls.append({"id": "", "type": "function", "function": {"name": "", "arguments": ""}}) | |
| if "id" in tc: tool_calls[index]["id"] += tc["id"] | |
| if "function" in tc: | |
| f = tc["function"] | |
| if "name" in f: tool_calls[index]["function"]["name"] += f["name"] | |
| if "arguments" in f: tool_calls[index]["function"]["arguments"] += f["arguments"] | |
| except: pass | |
| self._emit_token_usage("atlas", model_name, _usage_acc) | |
| if not tool_calls: | |
| # ๐ก๏ธ [Tool Runtime] ๋๊ตฌ ์๋ ์๋ต โ ๊ตฌ์ ์ฒ๋ฆฌ ์ผ์ํ | |
| _action = yield from self.runtime.handle_no_tool_calls(full_content, messages, ctx) | |
| if _action == "continue": | |
| continue | |
| break | |
| # ๐ก๏ธ [Tool Runtime] ๋๊ตฌ ํธ์ถ ์ผ๊ด ์คํ (๊ฒ์ฆยท๋ณด์ ยทask ๊ฐ๋ยท์ํท๋ธ๋ ์ด์ปค ๊ณตํต) | |
| yield from self.runtime.run_tool_calls(tool_calls, full_content, messages, ctx) | |
| except Exception as e: | |
| logger.error(f"Atlas Local Session Error: {e}") | |
| yield f"\nโ Local Atlas Malfunction: {str(e)}" | |
| def _wants_coding(self, prompt: str) -> bool: | |
| """ | |
| ๐ ๏ธ [Imperial Coding Gate] ์ฝ๋ฉ/ํ์ผ ์์ ์๋ ๊ฐ์ง. | |
| True๋ฉด ์์ด์ ํฑ ์ฝ๋ฉ ์์น(CODING_AGENT_GUIDE)์ ์์คํ ์ง์์ ์ฃผ์ ํ๊ณ , | |
| ๊ฒ์ ๊ทธ๋ผ์ด๋ฉ ๋ชจ๋๋ณด๋ค ์ฝ๋ฉ ๋ชจ๋๋ฅผ ์ฐ์ ํ๋ค. | |
| """ | |
| if not prompt: | |
| return False | |
| p = prompt.lower() | |
| coding_triggers = [ | |
| "์ฝ๋", "์ฝ๋ฉ", "๋ฒ๊ทธ", "์๋ฌ", "์ค๋ฅ", "์์ ํด", "๊ณ ์ณ", "๊ตฌํ", "๋ฆฌํฉํ ๋ง", "ํจ์", | |
| "ํ์ผ", "์คํฌ๋ฆฝํธ", "๋๋ฒ๊ทธ", "๋ถ์ํด", "๊ฒํ ", "ํ ์คํธ", "๋น๋", "์ปดํ์ผ", | |
| "code", "bug", "error", "fix", "implement", "refactor", "debug", "build", | |
| ".py", ".dart", ".js", ".ts", ".json", ".yaml", ".md", "api", "backend", "frontend", | |
| ] | |
| return any(t in p for t in coding_triggers) | |
| def _wants_obsidian(self, prompt: str) -> bool: | |
| """๐๏ธ [Imperial Obsidian Gate] ์ต์๋์ธ ๋ ธํธ/์ํค ์์ฑยท๊ฒ์ ์๋ ๊ฐ์ง. | |
| True๋ฉด ์ฝ๋ฉ ๋ชจ๋(ํ์ผ/ํฐ๋ฏธ๋ ๋๊ตฌ ๋ฒ๋)๋ฅผ ๋๊ณ obsidian_search/write ๋๊ตฌ๋ฅผ ๋ถ๊ฐ์ํจ๋ค. | |
| ('์ํค ๋ง๋ค์ด์ค'๊ฐ ์ฝ๋ฉ ์๋๋ก ๋นจ๋ ค๊ฐ obsidian_write ๋์ ๋ง๋ก๋ง ๋์ฐ๋ ๋ฌธ์ ์ฐจ๋จ) | |
| ๋ก์ปฌ ๋ณผํธ๊ฐ ๋นํ์ฑ์ด๋ฉด ๊ตณ์ด ๊ฒ์ดํธํ ํ์ ์์ผ๋ฏ๋ก False. | |
| """ | |
| if not prompt: | |
| return False | |
| try: | |
| import obsidian_vault as _ov | |
| if not _ov.is_enabled(): | |
| return False | |
| except Exception: | |
| return False | |
| p = prompt.lower() | |
| return ("์ต์๋์ธ" in p) or ("obsidian" in p) or ("๋ณผํธ" in p and "vault" in p) | |
| def _wants_obsidian_write(self, prompt: str) -> bool: | |
| """๐๏ธ ์ต์๋์ธ ์๋ ์ค '์ฐ๊ธฐ/์์ฑ/์ ์ฅ' ์๋์ธ์ง ๊ตฌ๋ถํ๋ค. | |
| ๊ฒ์('์ฐพ์/๊ฒ์/์๋ ค')์ ์ฐ๊ธฐ๊ฐ ์๋๋ผ ๊ทธ๋ผ์ด๋ฉ(์ปจํ ์คํธ ์ฃผ์ )์ผ๋ก ๋ตํด์ผ ํ๋ฏ๋ก, | |
| ์ฐ๊ธฐ ์๋์ผ ๋๋ง obsidian_write ๋๊ตฌ์ ์์ฑ-์ฌ์ด(nudge)์ ์ผ ๋ค. | |
| """ | |
| if not prompt: | |
| return False | |
| p = prompt.lower() | |
| _write_kw = ( | |
| "๋ง๋ค", "์์ฑ", "์ ์ฅ", "์ ๋ฆฌ", "๊ธฐ๋ก", "์จ์ค", "์จ ์ค", "์ ์ด", "์ถ๊ฐ", | |
| "๋ ธํธ๋ก", "๋ฉ๋ชจ", "์ ๋ฐ์ดํธ", "์์ ", "๊ฐฑ์ ", "write", "create", "save", "append", | |
| ) | |
| return any(k in p for k in _write_kw) | |
| def _obsidian_query(self, prompt: str) -> str: | |
| """์ต์๋์ธ ๊ฒ์์ฉ ํต์ฌ์ด ์ถ์ถ. ์์ชฝ ์ธ์ฌยท์ถ์ฒ์ด์ ๋ค์ชฝ ๊ฒ์ ๋ช ๋ น์ด๋ฅผ ๋ผ์ด๋ธ๋ค. | |
| ๋ถ์ฌ ์ด ๊ฒฝ์ฐ๋ ์ฒ๋ฆฌ: 'ํ์ด ํด๋ผ์ฐ๋ ์ต์๋์ธ์์ ttt์ฐพ์์ฃผ์ธ์' โ 'ttt'. | |
| (๋ช ๋ น์ด๊ฐ ๊ฒ์์ด์ ๋ถ์ด ์๋งจํฑ/ํค์๋๊ฐ ์๋ฑํ ๋ ธํธ๋ก ์ ๋ฆฌ๋ ๊ฒ์ ๋ฐฉ์ง.) | |
| ์ต์ปค(^/$) ๋ฐฉ์์ด๋ผ ๋ฌธ์ฅ ์ค๊ฐ์ ๊ฐ์ ๊ธ์(์: '๊ฒ์์์ง')๋ ๊ฑด๋๋ฆฌ์ง ์๋๋ค. | |
| """ | |
| if not prompt: | |
| return prompt | |
| q = prompt.strip() | |
| _lead = ( | |
| r"^(ํ์ด|์๋ ํ์ธ์|์๋ |ํค์ด|hi|hello|" | |
| r"ํด๋ผ์ฐ๋|cloud|๋ก์ปฌ|local|" | |
| r"์ต์๋์ธ์์|์ต์๋์ธ์|์ต์๋์ธ|obsidian|๋ณผํธ์์|๋ณผํธ|vault|" | |
| r"๋ ธํธ์์|๋ ธํธ|๋ฉ๋ชจ์์|๋ฉ๋ชจ)\s*" | |
| ) | |
| _tail = ( | |
| r"\s*(์์|์)?\s*(์ฐพ์์ฃผ์ธ์|์ฐพ์์ค|์ฐพ์ ์ค|์ฐพ์|๊ฒ์ํด์ฃผ์ธ์|๊ฒ์ํด์ค|๊ฒ์ํด|๊ฒ์|" | |
| r"์๋ ค์ฃผ์ธ์|์๋ ค์ค|์๋ ค|๋ณด์ฌ์ฃผ์ธ์|๋ณด์ฌ์ค|๋งํด์ค|์ฝ์ด์ค|ํด์ฃผ์ธ์|ํด์ค|์ฃผ์ธ์|์ค|์ข)$" | |
| ) | |
| # ๐ฐ๐ท ํ๊ตญ์ด๋ ์กฐ์ฌ๊ฐ ๋์ด์ฐ๊ธฐ ์์ด ๋ถ๋๋ค('abcd์๋ํด'). ๋ช ๋ น์ด ์ ๊ฑฐ ํ ๋จ๋ | |
| # ์ฐ๊ฒฐ์ด(์ ๋ํด/๊ด๋ จ/๋ํด/๊ดํด ๋ฑ)๋ฅผ ๋ผ์ด ํต์ฌ์ด๋ง ๋จ๊ธด๋ค. (๋จ์ผ ์กฐ์ฌ๋ | |
| # ๊ฒ์์์ง์ _strip_particle์ด ํค์๋๋ณ๋ก ๋ฐ๋ก ์ฒ๋ฆฌํ๋ฏ๋ก ์ฌ๊ธฐ์ ์ ๊ฑด๋๋ฆผ.) | |
| _conn = ( | |
| r"\s*(์\s*๋ํด์|์\s*๋ํด|์\s*๋ํ|์\s*๊ดํด์|์\s*๊ดํด|์\s*๊ดํ|" | |
| r"์๊ดํด|์๋ํด|๊ด๋ จํด์|๊ด๋ จ๋|๊ด๋ จํด|๊ด๋ จ|๋ํด์|๋ํด|๊ดํด์|๊ดํด)$" | |
| ) | |
| prev = None | |
| while q and q != prev: | |
| prev = q | |
| q = re.sub(_lead, "", q) | |
| q = re.sub(_tail, "", q) | |
| q = re.sub(_conn, "", q) | |
| q = q.strip() | |
| return q or prompt.strip() | |
| # ๐ง [Tool Policy] ๋ด์ฅ ํด๋ฐฑ ๊ธฐ๋ณธ๊ฐ โ DB ์ ์ฑ (tool_policy ์น์ )์ด ์๊ฑฐ๋ ์กฐํ ์คํจ ์ ์ฌ์ฉ. | |
| # ์ด์ ์กฐ์ ์ ์ฝ๋๊ฐ ์๋๋ผ ์ด์ง์ค '์๋ ๊ธฐ์ตยท๊ฒ์ ์ ์ฑ ' JSON์ tool_policy์์ ํ๋ค. | |
| _TOOL_POLICY_FALLBACK = { | |
| "intents": { | |
| "media": { | |
| "nouns": ["์ด๋ฏธ์ง", "์ฌ์ง", "๊ทธ๋ฆผ", "์์", "๋์์", "gif", "์์งค", "์งค", | |
| "๋ฐฐ๊ฒฝํ๋ฉด", "์ํ์ดํผ", "image", "photo", "picture", "video", "wallpaper", | |
| "3d", "3d๋ชจ๋ธ", "3d ๋ชจ๋ธ", "glb", "vrm", "์์ ", "3d ์์ ", | |
| "์ ํ๋ธ", "youtube"], | |
| "actions": ["์ฐพ์", "๊ฒ์", "๊ตฌํด", "๋ค์ด", "๋ฐ์", "๋ณด์ฌ", | |
| "search", "find", "download", "get"], | |
| "tools": ["search_media", "search_3d_model", "search_youtube"], | |
| "skip_grounding": True, | |
| }, | |
| "delegate": { | |
| "nouns": ["์ฝ๋ฑ์ค", "๋ฏธ๋๋งฅ์ค", "ํค๋ฅด๋ฉ์ค", "์๋น์ค", "์ํธ์", "๋๋ฃ", | |
| "codex", "minimax", "mavis", "hermes", "claude", "lm studio", "lmstudio"], | |
| "actions": ["์์ผ", "์์", "๋งก๊ฒจ", "๋ถํ", "๋ฌผ์ด", "๋ถ๋ด", "ํ์ ", "์ง์", | |
| "delegate", "ask", "assign"], | |
| "tools": ["delegate_to_guardian"], | |
| "skip_grounding": True, | |
| }, | |
| }, | |
| "tool_defaults": { | |
| "search_3d_model": {"limit": 8, "max": 20}, | |
| "search_media": {"limit": 8, "max": 20}, | |
| "search_youtube": {"limit": 6, "max": 20}, | |
| }, | |
| "search_engines": {"gemini_grounding": True, "serpapi": True, "playwright": True}, | |
| } | |
| def _tool_policy(self) -> dict: | |
| """DB ๊ฒ์ ์ ์ฑ ์ tool_policy ์น์ (TTL ์บ์). ์คํจ/๋๋ฝ ์ ๋ด์ฅ ํด๋ฐฑ.""" | |
| try: | |
| aegis_url, _ = self._resolve_aegis() | |
| if not aegis_url: | |
| aegis_url = os.environ.get("AEGIS_URL", "").rstrip("/") or None | |
| from utils.emotion import get_dynamic_retrieval_policy | |
| tp = (get_dynamic_retrieval_policy(aegis_url) or {}).get("tool_policy") | |
| if isinstance(tp, dict) and tp: | |
| return tp | |
| except Exception as e: # noqa: BLE001 | |
| logger.debug(f"[ToolPolicy] ์กฐํ ์คํจ โ ๋ด์ฅ ํด๋ฐฑ: {e}") | |
| return self._TOOL_POLICY_FALLBACK | |
| def _intent_cfg(self, name: str) -> dict: | |
| """์๋๋ณ ์ค์ (nouns/actions/tools/skip_grounding). ์ ์ฑ ์ฐ์ , ํด๋ฐฑ ๋ณํฉ.""" | |
| fb = (self._TOOL_POLICY_FALLBACK.get("intents") or {}).get(name) or {} | |
| cfg = ((self._tool_policy().get("intents") or {}).get(name)) or {} | |
| merged = dict(fb) | |
| merged.update({k: v for k, v in cfg.items() if v is not None}) | |
| return merged | |
| def _wants_delegate(self, prompt: str) -> bool: | |
| """๐ค [Imperial Orchestration Gate] ๋๋ฃ ์ํธ์ ์์ ์๋ ๊ฐ์ง. | |
| ํค์๋๋ DB ์ ์ฑ tool_policy.intents.delegate ์์ ์จ๋ค (๋ด์ฅ ํด๋ฐฑ ๋ณํฉ).""" | |
| if not prompt: | |
| return False | |
| p = prompt.lower() | |
| cfg = self._intent_cfg("delegate") | |
| nouns = cfg.get("nouns") or [] | |
| actions = cfg.get("actions") or [] | |
| return any(n in p for n in nouns) and any(a in p for a in actions) | |
| # ๐ค [Imperial Orchestration] ์ํธ์ ์์ ๋ผ์ฐํธ โ ํ๋ฏธ๋ CLI/ํค์๋ฌ๊ณผ ๋์ผ ์ค๊ณ ์ฌ์ฌ์ฉ | |
| _DELEGATE_ROUTES = { | |
| "codex": ("/api/shadow/codex/send", "context_id"), | |
| "minimax": ("/api/shadow/minimax-code/send", "sessionKey"), | |
| "hermes": ("/api/shadow/hermes/send", "sessionKey"), | |
| "claude": ("/api/shadow/claude-code/send", "context_id"), | |
| "lmstudio": ("/api/shadow/lmstudio/send", "sessionKey"), | |
| } | |
| _DELEGATE_ALIASES = { | |
| "์ฝ๋ฑ์ค": "codex", "๋ฏธ๋๋งฅ์ค": "minimax", "mavis": "minimax", | |
| "ํค๋ฅด๋ฉ์ค": "hermes", "์๋น์ค": "claude", "claude-code": "claude", | |
| "jarvis": "claude", "lm": "lmstudio", "lms": "lmstudio", "lm studio": "lmstudio", | |
| } | |
| def delegate_to_guardian(self, guardian: str = "", task: str = "", context: str = "") -> str: | |
| """๐ค ๋๋ฃ ์ํธ์(์ฝ๋ฑ์คยท๋ฏธ๋๋งฅ์คยทํค๋ฅด๋ฉ์คยท์๋น์คยทLM Studio)์๊ฒ ์์ ์ ์์ํ๊ณ | |
| ๊ทธ ์ํ ๊ฒฐ๊ณผ๋ฅผ ๋๋ ค๋ฐ๋๋ค. ๊ธฐํโ์ฝ๋ฉโํ ์คํธโ๊ฒ์ฆ ์ค์ผ์คํธ๋ ์ด์ ์ ๊ธฐ๋ณธ ๋จ์.""" | |
| g = self._DELEGATE_ALIASES.get((guardian or "").strip().lower(), | |
| (guardian or "").strip().lower()) | |
| if g not in self._DELEGATE_ROUTES: | |
| return (f"์ค๋ฅ: ์ ์ ์๋ ์ํธ์ '{guardian}'. " | |
| "์ฌ์ฉ ๊ฐ๋ฅ: codex(์ฝ๋ฉ), minimax(์ฝ๋ฉ/ํ ์คํธ), hermes, claude(๊ธฐํ/๊ฒ์ฆ), lmstudio") | |
| task = (task or "").strip() | |
| if not task: | |
| return "์ค๋ฅ: ์์ํ ์์ (task) ์ค๋ช ์ด ๋น์์ต๋๋ค." | |
| path, key_field = self._DELEGATE_ROUTES[g] | |
| message = f"[์๋์ฐ ๋ธ๋ ์ธ ์์ ์์ ]\n{task}" | |
| if (context or "").strip(): | |
| message += f"\n\n[์ฐธ๊ณ ์ปจํ ์คํธ]\n{context.strip()[:4000]}" | |
| payload = {"message": message, key_field: f"orchestrate-{g}"} | |
| if g in ("codex", "minimax"): | |
| try: | |
| from brain.coding_tools import _ROOT_OVERRIDE | |
| cwd = getattr(_ROOT_OVERRIDE, "value", None) | |
| except Exception: | |
| cwd = None | |
| if cwd: | |
| payload["cwd"] = cwd | |
| port = (os.environ.get("SHADOW_PORT") or "18700").strip() | |
| try: | |
| import requests as _rq | |
| logger.info(f"๐ค [Delegate] {g} โ {task[:80]}") | |
| r = _rq.post(f"http://127.0.0.1:{port}{path}", json=payload, timeout=600) | |
| data = r.json() if r.headers.get("content-type", "").startswith("application/json") else {} | |
| except Exception as e: | |
| return f"์์ ์คํจ({g}): {e}" | |
| if not data or data.get("status") == "ERROR": | |
| return f"{g} ์์ ์ค๋ฅ: {(data or {}).get('message') or f'HTTP {r.status_code}'}" | |
| content = (data.get("content") or "").strip() or "(๋น ์๋ต)" | |
| return f"[{g} ์ํ ๊ฒฐ๊ณผ]\n{content[:12000]}" | |
| def _wants_media(self, prompt: str) -> bool: | |
| """๐ผ๏ธ [Imperial Media Gate] ๋ก์ดํฐ-ํ๋ฆฌ ๋ฏธ๋์ด ๋ฐ 3D ์์ ๊ฒ์ ์๋ ๊ฐ์ง. | |
| True๋ฉด ๊ฒ์ ๊ทธ๋ผ์ด๋ฉ ๋์ ๋๊ตฌ(search_media, search_3d_model)๋ฅผ ์ฐ๋๋ก ํ๋ค. | |
| ํค์๋๋ DB ์ ์ฑ tool_policy.intents.media ์์ ์จ๋ค (์ฝ๋ ์์ ์์ด ์กฐ์ ๊ฐ๋ฅ). | |
| """ | |
| if not prompt: | |
| return False | |
| p = prompt.lower() | |
| cfg = self._intent_cfg("media") | |
| nouns = cfg.get("nouns") or [] | |
| actions = cfg.get("actions") or [] | |
| return any(n in p for n in nouns) and any(a in p for a in actions) | |
| def _wants_realtime_search(self, prompt: str) -> bool: | |
| """๐ ์ต์ /์ค์๊ฐ ์ ๋ณด ์ง๋ฌธ ๊ฐ์ง (Gemini ๊ฒฝ๋ก์ SEARCH_KEYWORDS์ ๋์ผ ์ทจ์ง).""" | |
| if not prompt: | |
| return False | |
| p = prompt.lower() | |
| keywords = [ | |
| "search", "find", "๊ฒ์", "์ฐพ์", "๊ตฌ๊ธ", "google", "์น๊ฒ์", "์น ๊ฒ์", | |
| "๋ด์ค", "news", "๋ ์จ", "weather", "๊ธฐ์จ", "์จ๋", "๋ฏธ์ธ๋จผ์ง", "์๋ณด", "์ด์", "์ฌ๊ฑด", "์ฌ๊ณ ", | |
| "์ต์ ", "์ค์๊ฐ", "์ค๋", "์ง๊ธ", "ํ์ฌ", "์ต๊ทผ", "์์ฆ", "์ด์ ", | |
| "์ฃผ๊ฐ", "์ฃผ์", "ํ์จ", "์ฝ์ธ", "๋นํธ์ฝ์ธ", "๊ฐ๊ฒฉ", "์์ธ", "์ผ๋ง", | |
| "๋ํต๋ น", "์์", "์ด๋ฆฌ", "์ฅ๊ด", "์์ฅ", "๊ตญํ์์", "์์", "๋๊ตฌ์ผ", "๋๊ตฌ์ง", "๋๊ตฌ์ธ", "๋๊ฐ", | |
| ] | |
| return any(k in p for k in keywords) | |
| def _wants_hugin(self, prompt: str) -> bool: | |
| """ | |
| ๐ฑ [Imperial Hugin Gate] ๋ง์๋์ด ๋ช ์์ ์ผ๋ก Hugin(๋ธ๋ผ์ฐ์ ์ ์ด)์ ์์ฒญํ๋์ง ๊ฐ์งํฉ๋๋ค. | |
| True์ผ ๋๋ง hugin_navigate/screenshot/click/type ๋๊ตฌ๊ฐ ๋ชจ๋ธ์ ๋ ธ์ถ๋ฉ๋๋ค. | |
| ๊ธฐ๋ณธ์ ์ ์ธ โ ๋ชจ๋ธ์ด ์ต๊ด์ ์ผ๋ก ๋ธ๋ผ์ฐ์ ์คํฌ๋ฆฐ์ท์ ํธ์ถํ๋ ๊ฒ์ ๋ง๊ธฐ ์ํจ์ ๋๋ค. | |
| """ | |
| if not prompt: | |
| return False | |
| p = prompt.lower() | |
| hugin_triggers = [ | |
| # ๋๊ตฌ ์ด๋ฆ ์ง์ ์ธ๊ธ | |
| "hugin", "ํ๊ธด", "ํด๊ธด", | |
| # ๋ธ๋ผ์ฐ์ ์ ์ด ์๋ | |
| "๋ธ๋ผ์ฐ์ ", "browser", "ํฌ๋กฌ", "chrome", "์นํ์ด์ง", "์น ํ์ด์ง", "์ฌ์ดํธ ์ด์ด", | |
| "ํ์ด์ง ์ด์ด", "url ์ด์ด", "์ฃผ์ ์ด์ด", "navigate", | |
| ] | |
| return any(t in p for t in hugin_triggers) | |
| def _wants_web_search(self, prompt: str) -> bool: | |
| """ | |
| ๐ฑ [Imperial Search Gate] ์น ๊ฒ์ ์๋ ๊ฐ์ง. | |
| Ollama ๋ณตํฉ ์๋ ์ฒ๋ฆฌ ์ obsidian_write + web_search ์กฐํฉ ๊ฒฐ์ ์ ์ฌ์ฉ. | |
| """ | |
| if not prompt: | |
| return False | |
| p = prompt.lower() | |
| search_triggers = [ | |
| "๊ฒ์", "์ฐพ์", "์์๋ด", "์ฐพ์๋ด", "์กฐ์ฌํด", "๊ฒ์ํด", "์ธํฐ๋ท์์", | |
| "์น์์", "๊ตฌ๊ธ", "google", "search", "์ฐพ์์ค", "์๋ ค์ค", | |
| "์ต์ ", "๋ด์ค", "์ ๋ณด ์ฐพ", "์ ๋ณด๋ฅผ ์ฐพ", | |
| ] | |
| return any(t in p for t in search_triggers) | |
| def _wants_delegation(self, prompt: str) -> bool: | |
| """ | |
| ๐ฑ [Imperial Delegation Gate] ๋ง์๋์ด ๋ช ์์ ์ผ๋ก ํ ์์/ํ๋๋ผ ๋์คํจ์น๋ฅผ | |
| ์์ฒญํ๋์ง ๊ฐ์งํฉ๋๋ค. True์ผ ๋๋ง delegate_team_task/dispatch_to_hydra ๋๊ตฌ๊ฐ | |
| ๋ชจ๋ธ์ ๋ ธ์ถ๋ฉ๋๋ค. ๊ธฐ๋ณธ์ ์ ์ธ โ ํ์ผ ๋ง๋ค๊ธฐ ๊ฐ์ ์ผ๋ฐ ์์ ์ ๋ชจ๋ธ์ด ์ต๊ด์ ์ผ๋ก | |
| ํ ์์/ํ๋๋ผ๋ฅผ ํธ์ถํด ์คํจํ๋ ๋ฌธ์ ๋ฅผ ๋ง๊ธฐ ์ํจ์ ๋๋ค. | |
| """ | |
| if not prompt: | |
| return False | |
| p = prompt.lower() | |
| delegation_triggers = [ | |
| # ํ ํ ์คํฌ / ์์ ์๋ | |
| "ํ ํ ์คํฌ", "ํํ ์คํฌ", "ํ ์คํฌ ๋ง๋ค", "ํ ์คํฌ ์์ฑ", "task ๋ง๋ค", "task ์์ฑ", | |
| "์์", "delegate", "๋งก๊ฒจ", "๋งก์", "๋ฐฐ์ ", "ํ ๋นํด", | |
| # ์ํธ์ ์ง๋ช / ํ๋๋ผ / ๋ณ๋ ฌ ๊ธฐํ | |
| "ํ๋๋ผ", "hydra", "์์ฟ ๋ฒค์ค", "acubens", "์๋ฅด๋ฉ์ค", "hermes", | |
| "์ํธ์์๊ฒ", "์ํธ์ํํ ", "ํ์๊ฒ", "ํํํ ", "๋ณ๋ ฌ", "๊ธฐํ ๋ณด๊ณ ์", | |
| "dispatch", | |
| ] | |
| return any(t in p for t in delegation_triggers) | |
| def _wants_os_control(self, prompt: str) -> bool: | |
| """ | |
| ๐ฑ [Imperial OS Control Gate] ๋ง์๋์ด ๋ช ์์ ์ผ๋ก OS ์ฑ ์คํ/ํค์ ๋ ฅ/ํ์ดํ์ | |
| ์์ฒญํ๋์ง ๊ฐ์งํ๋ค. True์ผ ๋๋ง sovereign_run_app/type_text/press_key ๋๊ตฌ๊ฐ ๋ ธ์ถ๋๋ค. | |
| ๊ธฐ๋ณธ ์ ์ธ โ 'ํ์ผ ๋ง๋ค์ด์ค' ๊ฐ์ ์์ ์ ๋ชจ๋ธ์ด notepad/cmd๋ฅผ ์คํํ๋ ค๋ค ์คํจํ๋ฉฐ | |
| ํ๋ฉด์ ์ด์ง๋ฝํ๋ ๋ฌธ์ ๋ฅผ ๋ง๊ธฐ ์ํจ์ด๋ค. (ํ์ผ์ MCP ํ์ผ๋๊ตฌ๋ก ์ถฉ๋ถ) | |
| """ | |
| if not prompt: | |
| return False | |
| p = prompt.lower() | |
| os_triggers = [ | |
| # ์ฑ ์คํ ์๋ | |
| "์คํํด", "์คํ์์ผ", "์ผ์ค", "์ด์ด์ค", "๊ตฌ๋", "launch", "run app", "open app", | |
| "๋ฉ๋ชจ์ฅ", "notepad", "๊ณ์ฐ๊ธฐ", "calc", "cmd", "ํฐ๋ฏธ๋", "ํ์์ ธ", "powershell", | |
| "ํ๋ก๊ทธ๋จ ์คํ", "์ฑ ์คํ", "์์ฉํ๋ก๊ทธ๋จ", | |
| # ํ์ดํ/ํค์ ๋ ฅ ์๋ ('์ณ์ค'๋ '๊ณ ์ณ์ค'์ ์ค๋งค์นญ๋์ด ์ ์ธ, 'ํ์ดํ ์ณ์ค'๋ก ํ์ ) | |
| "ํ์ดํ", "ํ์ดํ ์ณ", "ํค ๋๋ฌ", "ํค๋ฅผ ๋๋ฌ", "๋จ์ถํค", "์ํฐ ๋๋ฌ", "type ", "press key", | |
| ] | |
| return any(t in p for t in os_triggers) | |
| def _tool_allowed(tool_name: str, allowed_list: list) -> bool: | |
| """ | |
| ๐๏ธ [Guardian Job M1] ๋๊ตฌ ํ์ดํธ๋ฆฌ์คํธ ๋งค์นญ. | |
| allowed_list์ ๊ฐ ํญ๋ชฉ๊ณผ tool_name์ ๋น๊ตํ๋ค. ์์ผ๋์นด๋(*) ์ง์. | |
| ์) 'mcp_filesystem_*' ๋ 'mcp_filesystem_write_file' ์ ๋งค์นญ. | |
| """ | |
| if not allowed_list: | |
| return False | |
| import fnmatch | |
| for pattern in allowed_list: | |
| if not pattern: | |
| continue | |
| if pattern == tool_name or fnmatch.fnmatch(tool_name, pattern): | |
| return True | |
| return False | |
| # ๐ฑ [๋๊ตฌ ๋ ธ์ถ ํต์ผ] 3๊ฐ ๋ธ๋ ์ธ ๊ฒฝ๋ก(OpenAI/Gemini/ollama)๊ฐ ๊ฐ์ ๊ท์น์ ์ฐ๋๋ก ๊ณตํต ํฌํผ. | |
| _WHITELIST_BASE_TOOLS = { | |
| "jarvis_memory", "recall_memory", "count_history", | |
| "search_knowledge", "save_knowledge", "check_port", "find_process", "ask_user", | |
| } | |
| def _mcp_name_kept(self, name: str, flags: dict) -> bool: | |
| """MCP ๋๊ตฌ๋ฅผ ์ด๋ฒ ์์ฒญ์ ์ค์์ง ํ์ (๋ค์ดํฐ๋ธ ์ค๋ณต ์ ๊ฑฐ + ๋ฌด๊ฑฐ์ด ๊ทธ๋ฃน ์๋ ๊ฒ์ดํธ). | |
| name์ 'mcp_<server>_<tool>' ํ์. ๋ค์ดํฐ๋ธ/์ฝ๋ฉ ๋๊ตฌ๋ ์ฌ๊ธฐ์ ๊ฑฐ๋ฅด์ง ์๋๋ค(True). | |
| flags: {hugin(๋ธ๋ผ์ฐ์ง), search(์น๊ฒ์), coding(์ฝ๋ฉ)}. | |
| """ | |
| n = name or "" | |
| if not n.startswith("mcp_"): | |
| return True | |
| # 1) ๋ค์ดํฐ๋ธ ์ต์๋์ธ๊ณผ ์ค๋ณต๋๋ MCP ์ ๊ฑฐ โ ๋ค์ดํฐ๋ธ๊ฐ ๋ ๋น ๋ฅด๊ณ (๊ฐ์ ํ๋ก์ธ์ค) ํ ํฐ ์ ์ฝ. | |
| if n.endswith("_search_obsidian") or n.endswith("_read_obsidian") or n.endswith("_write_obsidian"): | |
| return False | |
| # 2) ๋ฌด๊ฑฐ์ด ๊ทธ๋ฃน์ ์๋๊ฐ ๋ง์ ๋๋ง ๋ ธ์ถ (playwright 23๊ฐ๊ฐ ํ์ ํ ํฐ์ ๋จน๋ ์ฃผ๋ฒ). | |
| if n.startswith("mcp_playwright"): | |
| return bool(flags.get("hugin")) | |
| if n.startswith("mcp_google-surf") or n.startswith("mcp_google_surf"): | |
| return bool(flags.get("search")) | |
| if n.startswith("mcp_filesystem"): | |
| return bool(flags.get("coding")) | |
| # imperial(taeminix) ๋ฑ ์๊ณ ๊ณ ์ ํ ๋๊ตฌ๋ ์ ์ง. | |
| return True | |
| def _apply_native_whitelist_openai(self, native_schemas: list) -> list: | |
| """์ง์ whitelist(tool_mode=='whitelist')๋ฅผ OpenAI ์คํค๋ง ๋ค์ดํฐ๋ธ ๋ชฉ๋ก์ ์ ์ฉ. | |
| Gemini ๊ฒฝ๋ก(unified_function_declarations)์ ๋์ผ ๊ท์น โ ๊ฒฝ๋ก ๊ฐ ์ผ๊ด์ฑ ํ๋ณด. | |
| """ | |
| cfg = getattr(self, "dynamic_config", None) or {} | |
| if (cfg.get("tool_mode") or "all").lower() != "whitelist": | |
| return native_schemas | |
| allowed = cfg.get("allowed_tools") or [] | |
| if not allowed: | |
| return native_schemas | |
| out = [] | |
| for t in native_schemas: | |
| nm = (t.get("function", {}) or {}).get("name", "") | |
| if nm in self._WHITELIST_BASE_TOOLS or self._tool_allowed(nm, allowed): | |
| out.append(t) | |
| return out | |
| def _load_equipped_skills(self, skill_names: list, max_chars_each: int = 1500) -> str: | |
| """ | |
| ๐๏ธ [Guardian Job M2] ์ฅ์ฐฉ๋ ์คํฌ ๋ฌธ์(.agent/skills/<name>/SKILL.md)๋ฅผ ์ฝ์ด | |
| ์์คํ ํ๋กฌํํธ์ ์ฃผ์ ํ '์ ๋ฌธ ์ง์' ํ ์คํธ๋ก ์กฐ๋ฆฝํ๋ค. | |
| - ํ ํฐ ์ ์ฝ: ์คํฌ๋น max_chars_each ๊น์ง๋ง (์๋ถ๋ถ = ํต์ฌ ์ง์นจ). | |
| - ์บ์ฑ: ํ์ผ ๋ด์ฉ์ self._skill_cache ์ ๋ณด๊ดํด ๋งค ์์ฒญ๋ง๋ค ๋์คํฌ ์ ์ฝ์. | |
| """ | |
| if not skill_names: | |
| return "" | |
| if not hasattr(self, "_skill_cache"): | |
| self._skill_cache = {} | |
| try: | |
| proj_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| except Exception: | |
| return "" | |
| skills_dir = os.path.join(proj_root, ".agent", "skills") | |
| blocks = [] | |
| for name in skill_names: | |
| if not name or not isinstance(name, str): | |
| continue | |
| if name in self._skill_cache: | |
| blocks.append(self._skill_cache[name]) | |
| continue | |
| # ๊ฒฝ๋ก ํ์ถ ๋ฐฉ์ง(์ด๋ฆ์ ์ฌ๋์/.. ์ฐจ๋จ) | |
| safe = os.path.basename(name.strip()) | |
| path = os.path.join(skills_dir, safe, "SKILL.md") | |
| if not os.path.exists(path): | |
| logger.warning(f"[Guardian Job M2] ์คํฌ ๋ฌธ์ ์์: {safe}") | |
| continue | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| text = f.read().strip() | |
| if len(text) > max_chars_each: | |
| text = text[:max_chars_each] + "\n...(์ดํ ์๋ต)" | |
| block = f"### ๐ ์ฅ์ฐฉ ์คํฌ: {safe}\n{text}" | |
| self._skill_cache[name] = block | |
| blocks.append(block) | |
| logger.info(f"[Guardian Job M2] ์คํฌ ์ฃผ์ : {safe} ({len(text)}์)") | |
| except Exception as e: | |
| logger.warning(f"[Guardian Job M2] ์คํฌ ์ฝ๊ธฐ ์คํจ ({safe}): {e}") | |
| if not blocks: | |
| return "" | |
| return ( | |
| "\n\n## ๐๏ธ ์ ๋ฌธ ์ง์ (์ฅ์ฐฉ๋ ์คํฌ)\n" | |
| "์๋๋ ๋น์ ์ ์ง์ ์ ๋ถ์ฌ๋ ์ ๋ฌธ ์ง์์ด๋ค. ์์ ์ ์ด ์ง์นจ์ ์ฐ์ ์ ์ฉํ๋ผ.\n\n" | |
| + "\n\n".join(blocks) | |
| ) | |
| def _is_task_request(self, prompt: str, images: list = None) -> bool: | |
| """ | |
| ๐ฑ [Imperial Task Detector] ์์ ํ ์์ฒญ ์ฌ๋ถ๋ฅผ ๊ฐ์งํฉ๋๋ค. | |
| ํ์ผ/์ฝ๋/UI/๋ฒ๊ทธ ๊ด๋ จ ํค์๋๊ฐ ์์ผ๋ฉด True๋ฅผ ๋ฐํํฉ๋๋ค. | |
| True์ธ ๊ฒฝ์ฐ Gemini์๊ฒ ๋๊ตฌ ํธ์ถ์ ๊ฐ์ (mode=ANY)ํฉ๋๋ค. | |
| [๐ฑ Imperial Fix v2.7] ์ด๋ฏธ์ง๊ฐ ์ฒจ๋ถ๋๋ค๋ ์ฌ์ค๋ง์ผ๋ก ๋๊ตฌ๋ฅผ ๊ฐ์ ํ์ง ์๋๋ค. | |
| '์ด ์ด๋ฏธ์ง ๋ญ๊ฐ ๋ณด์ฌ?' ๊ฐ์ ์์ ๋ถ์/๊ฐ์ ์ง๋ฌธ์ ๋๊ตฌ ์์ด ๋ฉํฐ๋ชจ๋ฌ๋ก | |
| ๊ทธ๋ฅ ๋ณด๊ณ ๋ตํด์ผ ํ๊ธฐ ๋๋ฌธ์ด๋ค. ์ด๋ฏธ์ง๊ฐ ์์ด๋ ์๋ ์์ ํค์๋๊ฐ | |
| ํจ๊ป ์์ ๋๋ง(์: '์ด๋ฏธ์ง ๋ณด๊ณ CSS ๊ณ ์ณ์ค') ๋๊ตฌ๋ฅผ ๊ฐ์ ํ๋ค. | |
| """ | |
| # [๐ฑ Imperial Fix v2.7] '์ฒจ๋ถ', '์คํฌ๋ฆฐ์ท', '์ด๋ฏธ์ง๋ฅผ', '์ฌ์ง์' ๋ฑ์ | |
| # ์ด๋ฏธ์ง๊ฐ ์ฒจ๋ถ๋๋ค๋ ์ฌ์ค์ ๊ฐ๋ฆฌํฌ ๋ฟ ์์ ์๋๊ฐ ์๋๋ฏ๋ก ์์ ํค์๋์์ ์ ์ธ. | |
| # ('์ฒจ๋ถํ ์ด๋ฏธ์ง๊ฐ ๋ญ์ง ๋ณด์ด๋์?' ๊ฐ์ ์์ ๊ฐ์ ์ง๋ฌธ์ด ๋๊ตฌ ๊ฐ์ ๋ก ๋น ์ง์ง ์๊ฒ ํจ) | |
| task_keywords = [ | |
| # ํ๊ตญ์ด ์์ ํค์๋ (์ค์ ๋ณ๊ฒฝ/์กฐ์ ์๋) | |
| "์์ ", "๊ณ ์ณ", "๊ณ ์น", "๋ณ๊ฒฝ", "๋ฐ๊ฟ", "๋ฐ๊พธ", "์ถ๊ฐ", "์ญ์ ", "์ ๊ฑฐ", "๊ตฌํ", | |
| "๋ฒ๊ทธ", "์ค๋ฅ", "์๋ฌ", "error", "๊ณ ์ฅ", "์ ๋จ", "์๋จ", "๋ฌธ์ ", "์ด์", | |
| "ํ์ผ", "์ฝ๋", "css", "ui", "๋ ์ด์์", | |
| "๊ฒํ ", "๋ถ์", "์ฐพ์์ค", "๋๋ฒ๊ทธ", | |
| "์ฌ์ ํ", "์์ง๋", | |
| # ์์ด ์์ ํค์๋ | |
| "fix", "modify", "change", "update", "implement", "debug", | |
| "read file", "edit file", "write file", "inspect", | |
| ] | |
| prompt_lower = prompt.lower() | |
| return any(kw in prompt_lower for kw in task_keywords) | |
| def _should_use_google_grounding(self, prompt: str, aegis_url: str = None) -> bool: | |
| text = (prompt or "").strip().lower() | |
| if not text: | |
| return False | |
| from utils.emotion import get_dynamic_retrieval_policy | |
| policy = get_dynamic_retrieval_policy(aegis_url) | |
| if policy.get("grounding_enabled") is not True: | |
| return False | |
| for pattern in policy.get("grounding_exclude_patterns") or []: | |
| try: | |
| if re.search(str(pattern), text, re.IGNORECASE): | |
| return False | |
| except re.error: | |
| logger.warning("[RetrievalPolicy] ์๋ชป๋ ์ ์ธ ์ ๊ท์: %s", pattern) | |
| keywords = [str(k).lower() for k in (policy.get("grounding_keywords") or []) if str(k).strip()] | |
| return any(keyword in text for keyword in keywords) | |
| def _observe(self, event, reason="", severity="warning", meta=None, aegis_url=None, token=None): | |
| """๐ญ ๊ด์ธก ์ด๋ฒคํธ๋ฅผ ์ด์ง์ค ์ด๋ฒคํธ ์ ์ฅ์๋ก best-effort ์ ์ก (๋จ์ผ ๊ด์ธก ์ง์ ํตํฉ). | |
| ์ ๋ ์์ธ๋ฅผ ๋์ง์ง ์๋๋ค โ ๊ด์ธก์ด ๋ณธ ๊ธฐ๋ฅ์ ๊นจ์ ์ ๋๋ค.""" | |
| try: | |
| logger.warning("[OBSERVE:%s] %s | %s | %s", severity, event, str(reason)[:200], meta or {}) | |
| except Exception: | |
| pass | |
| try: | |
| api = self._aegis_api_url( | |
| aegis_url or getattr(self._request_ctx, "aegis_url", None) | |
| or os.environ.get("AEGIS_URL"), "/system/events") | |
| if not api: | |
| return | |
| tok = token or os.environ.get("GUARDIAN_SERVICE_TOKEN") or os.environ.get("IMPERIAL_API_TOKEN") | |
| headers = {"Content-Type": "application/json"} | |
| if tok: | |
| headers["Authorization"] = f"Bearer {tok}" | |
| headers["X-Imperial-Token"] = tok | |
| requests.post(api, json={ | |
| "event": event, "reason": str(reason)[:2000], "severity": severity, | |
| "source": "shadow_brain", "meta": meta or {}, | |
| }, headers=headers, timeout=4) | |
| except Exception as e: # noqa: BLE001 | |
| logger.debug("[OBSERVE] ์ด์ง์ค ์ ์ก ์คํจ(๋ฌด์): %s", e) | |
| def _aegis_api_url(self, aegis_url: str, path: str) -> str: | |
| base = (aegis_url or "").strip().rstrip("/") | |
| if not base: | |
| return "" | |
| if not path.startswith("/"): | |
| path = f"/{path}" | |
| if base.endswith("/api"): | |
| return f"{base}{path}" | |
| return f"{base}/api{path}" | |
| def _search_grounding_context(self, query: str, aegis_url: str = None, token: str = None, | |
| context_id: str = None) -> str: | |
| api_url = self._aegis_api_url(aegis_url, "/chat/search-grounding") | |
| if not api_url: | |
| raise ValueError("X-Aegis-URL header is required for Ollama search grounding.") | |
| model = getattr(self, "search_grounding_model", None) or "gemini-2.5-flash" | |
| headers = {"Content-Type": "application/json"} | |
| if token: | |
| headers["Authorization"] = f"Bearer {token}" | |
| headers["X-Imperial-Token"] = token | |
| headers["X-Guardian-Token"] = token | |
| response = requests.post( | |
| api_url, | |
| json={ | |
| "query": query, | |
| "model": model, | |
| "context_id": context_id | |
| or getattr(self._request_ctx, "session_id", None) | |
| or "GLOBAL", | |
| }, | |
| headers=headers, | |
| # ๐ฑ Django ๊ทธ๋ผ์ด๋ฉ ๋ทฐ(Gemini ํธ์ถ 60s) ๋ณด๋ค ๊ธธ๊ฒ ๋ฌ์ผ 502/์๋ฆผ ์์ด ๊ฒฐ๊ณผ๋ฅผ ๋ฐ๋๋ค. | |
| timeout=70, | |
| ) | |
| if response.status_code != 200: | |
| try: | |
| detail = response.json().get("message") or response.text | |
| except Exception: | |
| detail = response.text | |
| raise RuntimeError(f"Search grounding failed ({response.status_code}): {detail}") | |
| data = response.json() | |
| if data.get("status") not in ("SUCCESS", "OK", None): | |
| raise RuntimeError(data.get("message") or "Search grounding returned an error.") | |
| text = str(data.get("text") or "").strip() | |
| if not text: | |
| raise RuntimeError("Search grounding returned empty text.") | |
| # ๐ ์ค์ ์ฌ์ฉ๋ ๊ฒ์ ์์ง (Aegis๊ฐ SerpAPI ํด๋ฐฑ ์ provider='serpapi') | |
| _provider = str(data.get("provider") or "") | |
| self._last_grounding_engine = ( | |
| "SerpAPI (๊ตฌ๊ธ ๊ฒ์ ๊ฒฐ๊ณผ)" if _provider == "serpapi" | |
| else f"Google Gemini ๊ทธ๋ผ์ด๋ฉ ({data.get('model') or model})" | |
| ) | |
| sources = data.get("sources") or [] | |
| lines = [ | |
| f"[Web Search Grounding Context โ {self._last_grounding_engine}]", | |
| "Use this live web search grounding context before relying on model memory.", | |
| "", | |
| text, | |
| ] | |
| if isinstance(sources, list) and sources: | |
| lines.extend(["", "[Sources]"]) | |
| for source in sources[:6]: | |
| if isinstance(source, dict): | |
| title = str(source.get("title") or "").strip() | |
| uri = str(source.get("uri") or "").strip() | |
| if title or uri: | |
| lines.append(f"- {title} {uri}".strip()) | |
| return "\n".join(lines) | |
| def _build_ollama_prompt(self, prompt: str, system_instruction: str = None, history: list = None, search_context: str = None) -> str: | |
| blocks = [] | |
| if system_instruction: | |
| blocks.append(f"[System]\n{system_instruction}") | |
| if history and isinstance(history, list): | |
| history_lines = [] | |
| for msg in history[-10:]: | |
| role = msg.get("role", "user") | |
| content = msg.get("content", "") | |
| if content: | |
| history_lines.append(f"{role}: {content}") | |
| if history_lines: | |
| blocks.append("[Recent conversation]\n" + "\n".join(history_lines)) | |
| if search_context: | |
| blocks.append( | |
| f"{search_context}\n\n" | |
| "[Important rules]\n" | |
| "- The Google Search Grounding context above has priority over model memory.\n" | |
| "- For current people, offices, schedules, prices, laws, and dates, answer from the newest search context.\n" | |
| "- If search context conflicts with model memory, follow the search context.\n" | |
| "- Answer in Korean unless the user asks otherwise." | |
| ) | |
| blocks.append(f"[User question]\n{prompt}") | |
| return "\n\n".join(blocks) | |
| def _think_ollama( | |
| self, | |
| prompt: str, | |
| model_name: str, | |
| history: list = None, | |
| system_instruction: str = None, | |
| images: list = None, | |
| token: str = None, | |
| aegis_url: str = None, | |
| skip_tools: bool = False, | |
| tools: list = None, | |
| skip_grounding: bool = False, | |
| stream_tokens: bool = False, | |
| ) -> Generator[str, None, None]: | |
| """Local Ollama provider with optional Aegis Google Search Grounding and Tool-Calling support.""" | |
| _op_t0 = time.time() | |
| _op_lap = [time.time()] | |
| def _oplap(name): | |
| now = time.time() | |
| if now - _op_lap[0] > 0.15: | |
| logger.info(f"โฑ๏ธ [ollama-prep] {name}: {(now - _op_lap[0])*1000:.0f}ms") | |
| _op_lap[0] = now | |
| try: | |
| import dotenv | |
| # ์๋์ฐ ๋ธ๋ ์ธ ๋ฃจํธ ๋ฐ ํ๋ฏผ๊ฒ์์ฆ ํ๋ก์ ํธ ๋ฃจํธ์ .env๋ฅผ ์ ๋ฐํ๊ฒ ์ฌ๋ถ์ฐฉ | |
| _proj_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| _env_path = os.path.join(_proj_root, ".env") | |
| if os.path.exists(_env_path): | |
| dotenv.load_dotenv(_env_path, override=False) | |
| except Exception as dotenv_err: | |
| logger.debug(f"[Ollama Core] Dotenv dynamic reload failed: {dotenv_err}") | |
| self._load_shadow_settings() | |
| # ๐ผ๏ธ [Multimodal] ์ด๋ฏธ์ง๊ฐ ์ฒจ๋ถ๋๋ฉด Ollama ๋ค์ดํฐ๋ธ ํ์(message.images = [base64])์ผ๋ก | |
| # user ๋ฉ์์ง์ ์ค์ด ๋ณด๋ธ๋ค. gemma3/4(12b ๋ฑ) ๋น์ ๋ชจ๋ธ์ด๋ฉด ์ด๋ฏธ์ง๋ฅผ ๋ณด๊ณ ๋ถ์ํ๋ค. | |
| # (๋ฏธ์ง์ ๋ชจ๋ธ์ด๋ฉด Ollama๊ฐ ๋ฌด์/์๋ฌ โ ๋ ์ด์ 'text-only'๋ก ํ๋ ์ฐจ๋จํ์ง ์๋๋ค.) | |
| _ollama_images = [] | |
| if images: | |
| for _img in images: | |
| _data = (_img.get("data") if isinstance(_img, dict) else _img) or "" | |
| if isinstance(_data, str) and "," in _data and _data.strip().startswith("data:"): | |
| _data = _data.split(",", 1)[1] | |
| if _data: | |
| _ollama_images.append(_data) | |
| if _ollama_images: | |
| logger.info(f"๐ผ๏ธ [Ollama] ๋ฉํฐ๋ชจ๋ฌ ์ ๋ ฅ โ ์ด๋ฏธ์ง {len(_ollama_images)}์ฅ์ ๋ชจ๋ธ '{model_name}'์ ์ ๋ฌ.") | |
| search_context = None | |
| # ๐๏ธ [์ต์๋์ธ ๊ทธ๋ผ์ด๋ฉ] "์ต์๋์ธ์์ ~ ์ฐพ์์ค"(๊ฒ์ ์๋)๋ ์ฝํ ๋ก์ปฌ ๋ชจ๋ธ์ ๋ถ์์ ํ | |
| # ๋๊ตฌํธ์ถ/์์ฑ-์ฌ์ด ๋ฃจํ(๋ฌดํ '์๊ฐ์ค') ๋์ , ์๋ฒ๊ฐ ์ง์ ์ต์๋์ธ์ ๊ฒ์ํด ๊ฒฐ๊ณผ๋ฅผ | |
| # ์ปจํ ์คํธ๋ก ์ฃผ์ ํ๊ณ ๋ชจ๋ธ์ ํ ๋ฒ์ ๋ตํ๋ค(๊ตฌ๊ธ ๊ทธ๋ผ์ด๋ฉ๊ณผ ๋์ผ ํจํด). ์ฐ๊ธฐ ์๋๋ ์ ์ธ. | |
| _oplap("dotenv-settings") | |
| _obs_search_intent = self._wants_obsidian(prompt) and not self._wants_obsidian_write(prompt) | |
| if _obs_search_intent: | |
| try: | |
| # โ ๏ธ '๐ ์ต์๋์ธ์์ ๊ฒ์ํฉ๋๋ค...' ์๋ด๋ yieldํ์ง ์๋๋ค. yieldํ๋ฉด ๊ทธ๊ฒ | |
| # ์คํธ๋ฆฌ๋ฐ ์๋ต ๋ณธ๋ฌธ์ ์์ฌ ๋ฒ๋ธ์ ๋จ๊ณ (์ฌ์ ์ํด์ผ ์ฌ๋ผ์ง) ์ ์ฅ/๋ฒกํฐํ๊น์ง ํ๋ค. | |
| # '์๊ฐ์ค' ๋ฒ๋ธ์ด ์ด๋ฏธ ์งํ ํ์๋ฅผ ํ๋ฏ๋ก ๋ณ๋ ์๋ด ์์ด ์กฐ์ฉํ ๊ฒ์ํ๋ค. | |
| _pl = prompt.lower() | |
| _obs_src = "cloud" if ("ํด๋ผ์ฐ๋" in _pl or "cloud" in _pl) else None | |
| _obs_ctx = self.obsidian_search(self._obsidian_query(prompt), limit=5, source=_obs_src) | |
| if _obs_ctx and "์์" not in _obs_ctx[:60] and "๋นํ์ฑ" not in _obs_ctx[:60]: | |
| search_context = _obs_ctx | |
| except Exception as _obs_err: | |
| logger.warning(f"[Obsidian Grounding] ์คํจ: {_obs_err}") | |
| _wants_med = self._wants_media(prompt) | |
| _med_skip_grounding = bool(self._intent_cfg("media").get("skip_grounding", True)) | |
| if ( | |
| not skip_grounding | |
| and search_context is None | |
| and not (_wants_med and _med_skip_grounding) | |
| and self._should_use_google_grounding(prompt, aegis_url=aegis_url) | |
| ): | |
| # ๐ง [์ ๊ตญ ์ง์ ์ฐ์ ๊ท์ฝ] ์น ๊ฒ์ ์ ์ ์ง์ ๋ฒ ์ด์ค๋ฅผ ๋จผ์ ์กฐํํ๋ค. | |
| # ์ ๊ตญ ๊ณ ์ ๋ช ์ฌ(๋ฃจ๋ ๊ฒ์ดํธ ๋ฑ)๋ฅผ ์น์ด ๋๋ช ์ด์ธ(NASA Lunar Gateway ๋ฑ)์ผ๋ก | |
| # ์ค๋ตํ๋ ์ฌ๊ณ ๋ฐฉ์ง โ ์ต์๋์ธ ๊ทธ๋ผ์ด๋ฉ๊ณผ ๋์ผํ ์๋ฒ์ธก ์ฃผ์ ํจํด. | |
| # ํํธ๊ฐ ์์ผ๋ฉด ๊ทธ ์ฌ์ค์ ์ปจํ ์คํธ๋ก ์ฐ๊ณ ์น ๊ฒ์์ ์๋ตํ๋ค. | |
| try: | |
| _kb = self.search_knowledge(prompt[:120], limit=3) | |
| if _kb and _kb.startswith("์ด์ ์ง์ ๋ฒ ์ด์ค ๊ฒ์ ๊ฒฐ๊ณผ:") and "๊ด๋ จ ์ง์ ์์" not in _kb[:40]: | |
| search_context = _kb | |
| logger.info("๐ง [KB Grounding] ์ง์ ๋ฒ ์ด์ค ํํธ โ ์น ๊ฒ์ ์๋ต") | |
| except Exception as _kb_err: | |
| logger.warning(f"[KB Grounding] ์ง์ ๋ฒ ์ด์ค ํ๋ฆฌํจ์ค ์คํจ(๋ฌด์): {_kb_err}") | |
| if ( | |
| not skip_grounding | |
| and search_context is None | |
| and not (_wants_med and _med_skip_grounding) | |
| and self._should_use_google_grounding(prompt, aegis_url=aegis_url) | |
| ): | |
| try: | |
| if aegis_url: | |
| yield "๐ ์น ๊ฒ์์ ์คํํฉ๋๋ค...\n\n" | |
| search_context = self._search_grounding_context(prompt, aegis_url=aegis_url, token=token) | |
| _engine = getattr(self, "_last_grounding_engine", "") | |
| if _engine: | |
| yield f"๐ ๊ฒ์ ์์ง: {_engine}\n\n" | |
| else: | |
| raise ValueError("Aegis URL ๋ฏธ์ค์ โ Playwright ํด๋ฐฑ") | |
| except Exception as search_err: | |
| _pw_on = ((self._tool_policy().get("search_engines") or {}).get("playwright")) is not False | |
| if not _pw_on: | |
| logger.warning(f"[Ollama Grounding] ๊ทธ๋ผ์ด๋ฉ ์คํจ, Playwright๋ ์ ์ฑ ์ ๋นํ์ฑ: {search_err}") | |
| yield f"โ ๏ธ ๊ฒ์ ์คํจ (ํด๋ฐฑ ์์ง ๋นํ์ฑ): {search_err}" | |
| return | |
| logger.warning(f"[Ollama Grounding] ๊ทธ๋ผ์ด๋ฉ ์คํจ โ Playwright ํด๋ฐฑ: {search_err}") | |
| self._observe('grounding_fallback', str(search_err), severity='warning', | |
| meta={'from': 'aegis_grounding', 'to': 'playwright'}, aegis_url=aegis_url, token=token) | |
| try: | |
| yield "๐ ๋ก์ปฌ Playwright ๋ธ๋ผ์ฐ์ ๊ฒ์์ ์คํํฉ๋๋ค...\n\n" | |
| search_context = self.browser.local_playwright_search(prompt) | |
| except Exception as pw_err: | |
| logger.error(f"[Ollama Grounding] Playwright ํด๋ฐฑ๋ ์คํจ: {pw_err}") | |
| yield f"โ ๏ธ ๊ฒ์ ์คํจ: {pw_err}" | |
| return | |
| _oplap("obsidian+grounding-check") | |
| # ๐ [Guardian Job M2] ์ฅ์ฐฉ ์คํฌ ์ฃผ์ โ ์์ ์ Gemini ๊ฒฝ๋ก์๋ง ์์ด | |
| # ๋ก์ปฌ ํ๋ฏธ๋(Ollama)๋ ์ง์ ์ ์คํฌ์ ์ฅ์ฐฉํด๋ ์ ํ ๋ฐ์๋์ง ์์๋ค(2026-07-10 ๋ฐ๊ฒฌ). | |
| _equipped = (self.dynamic_config or {}).get("equipped_skills") or [] | |
| if _equipped: | |
| _skill_text = self._load_equipped_skills(_equipped) | |
| if _skill_text: | |
| system_instruction = (system_instruction or "") + _skill_text | |
| # ๐ CLI/ํค์๋ฌ '์คํฌ ์ฅ์ฐฉ' ํ์์ฉ ๋ง์ปค (STAGE ํ์ดํ๋ผ์ธ) | |
| yield f"[STAGE:๐ ์คํฌ ์ฅ์ฐฉ: {' ยท '.join(str(x) for x in _equipped[:4])}]\n" | |
| # 1. messages ํฌ๋งท์ผ๋ก ๊ตฌ์ฑ (/api/chat ์ง์) | |
| messages = [] | |
| # [Defensive Instruction for tools] | |
| defensive_instruction = ( | |
| "\n\n[โ ๏ธ ์ค์ ๊ท์ฝ: ๋๊ตฌ ์ฌ์ฉ ์ ํ]\n" | |
| "1. ์ผ๋ฐ์ ์ธ ์ฑํ ์๋ต์ด๋ ์ธ์ฌ๋ ๋ฐ๋์ ํ ์คํธ(Message)๋ก๋ง ์ํํ์ญ์์ค.\n" | |
| "2. 'sovereign_type_text'๋ 'sovereign_press_key'์ ๊ฐ์ OS ์ ์ด ๋๊ตฌ๋ ์ฌ์ฉ์๊ฐ ๋ช ์์ ์ผ๋ก " | |
| "์์คํ ์กฐ์์ด๋ ํ์ดํ์ ์์ฒญํ ๊ฒฝ์ฐ์๋ง ์ฌ์ฉํ์ญ์์ค.\n" | |
| "3. ์ ๋๋ก ์ฑํ ์๋ต์ ํ์ดํ ๋๊ตฌ๋ก ๋์ ํ์ง ๋ง์ญ์์ค.\n" | |
| "4. [์ค์] Playwright ๋ธ๋ผ์ฐ์ ๋๊ตฌ(mcp_playwright_browser_navigate)๋ฅผ ํธ์ถํ ๋, " | |
| "url ์ธ์๊ฐ์ ์ ๋๋ก ๋น์๋๊ฑฐ๋ ๋น JSON ({})์ผ๋ก ๋ณด๋ด์ง ๋ง์ญ์์ค. ๋ํ url ์ธ์์ ๊ฒ์์ด(์: '๋ํ๋ฏผ๊ตญ ๋ํต๋ น')๋ ์ผ๋ฐ ์ง๋ฌธ์ ๋ฃ์ง ๋ง์ญ์์ค. " | |
| "๋ฐ๋์ 'url': 'https://www.google.com' ๋๋ 'https://www.naver.com'๊ณผ ๊ฐ์ ์ค์ http/https URL ์ฃผ์๋ฅผ ๋ฃ์ด ๋จผ์ ํธ์ถํ์ญ์์ค.\n" | |
| "5. ๋ธ๋ผ์ฐ์ ํ์ด์ง์ ๋ค์ด๊ฐ ํ ๊ฒ์์ฐฝ ๋ฑ ์ ๋ ฅ ์์์ ํ์ดํ์ ํ ๋๋ `mcp_playwright_browser_fill_form` ์ด๋ `mcp_playwright_browser_type`์ ์ฌ์ฉํ๊ณ , " | |
| "๋ฐ๋์ selector ํน์ element ์๋ณ์๊ฐ ์ฌ๋ฐ๋ฅธ์ง ์ค๋ ์ท(`mcp_playwright_browser_snapshot`)์ ๋จผ์ ์ป์ด ํ์ธํ์ญ์์ค. Google์ ๊ฒฝ์ฐ ํ ์คํธ ์ ๋ ฅ ํ 'enter' key press๋ฅผ ์ถ๊ฐ๋ก ํธ์ถํด์ผ ๊ฒ์์ด ์คํ๋ฉ๋๋ค." | |
| ) | |
| # ๐ ๋ฐฉ์ด ์ง์นจ์ '๋๊ตฌ ์ฌ์ฉ ๊ท์จ'์ด๋ผ ๋๊ตฌ ์๋ ์ก๋ด์ ๋ถํ์ํ ๊ตฐ๋๋๊ธฐ. | |
| # inject_guidelines(์ ๊ตญ ๊ฐ์ด๋๋ผ์ธ) ๊บผ์ง๋ฉด ํจ๊ป ๋นผ์ ๋ก์ปฌ Ollama ์ฑ๋๊ณผ | |
| # ๋์ผํ๊ฒ ํ๋กฌํํธ๋ฅผ ์ต์ํํ๋ค(์์คํ ํ๋กฌํํธ 0์ ๊ฐ๊น๊ฒ). | |
| full_system = (system_instruction or "") | |
| if (self.dynamic_config or {}).get("inject_guidelines", True): | |
| full_system += defensive_instruction | |
| if full_system.strip(): | |
| messages.append({"role": "system", "content": full_system}) | |
| if history and isinstance(history, list): | |
| for msg in history[-10:]: | |
| role = msg.get("role", "user") | |
| content = msg.get("content", "") | |
| if content: | |
| messages.append({"role": role, "content": content}) | |
| # ์ต์ข user ํ๋กฌํํธ์ ๊ฒ์ ๊ทธ๋ผ์ด๋ฉ ์ปจํ ์คํธ ๊ฒฐํฉ | |
| user_content = "" | |
| if search_context: | |
| # [๐ฑ Ollama ์ ์ฉ] ๊ฒ์ ๊ฒฐ๊ณผ ์ต๋ 2000์๋ก ์ ํ | |
| # ๋๋ฌด ๊ธธ๋ฉด gemma4๊ฐ ๊ฒ์ ๊ฒฐ๊ณผ์ ์์คํ ํ๋กฌํํธ๋ฅผ ํผ๋ํ์ฌ ์๋ชป๋ ๋ด์ฉ์ ์ ์ฅ | |
| _ctx_limit = 2000 | |
| _ctx_trimmed = search_context[:_ctx_limit] | |
| if len(search_context) > _ctx_limit: | |
| _ctx_trimmed += "\n...(๊ฒ์ ๊ฒฐ๊ณผ ์ผ๋ถ ์๋ต)" | |
| user_content += f"{_ctx_trimmed}\n\n" | |
| user_content += prompt | |
| _user_msg = {"role": "user", "content": user_content} | |
| if _ollama_images: | |
| _user_msg["images"] = _ollama_images # ๐ผ๏ธ Ollama ๋ค์ดํฐ๋ธ ๋ฉํฐ๋ชจ๋ฌ | |
| messages.append(_user_msg) | |
| # 2. ๋๊ตฌ ํ๋ | |
| # [๐ฑ Ollama ์ ์ฉ] ์ฝํ ๋ก์ปฌ ๋ชจ๋ธ ์ค๋ฒ๋ก๋ ๋ฐฉ์ง: | |
| # ์๋๋ณ ํ์ ๋๊ตฌ๋ง ์ต์ ์ ๋ฌ (์ต๋ 3๊ฐ) โ gemma4 ๊ณผ๋ถํ ๋ฐฉ์ง | |
| # ๐๏ธ ์ต์๋์ธ '์ฐ๊ธฐ' ์๋์ผ ๋๋ง obsidian_write ๋๊ตฌ๋ฅผ ๋ถ์ธ๋ค. '๊ฒ์' ์๋๋ ์์์ | |
| # ์ด๋ฏธ ๊ทธ๋ผ์ด๋ฉ์ผ๋ก ์ปจํ ์คํธ๋ฅผ ์ฃผ์ ํ์ผ๋ฏ๋ก ๋๊ตฌ(=๋ถ์์ ํ ๋ฃจํ) ์์ด ๋ตํ๊ฒ ํ๋ค. | |
| _wants_obs = not skip_tools and self._wants_obsidian(prompt) and self._wants_obsidian_write(prompt) | |
| _wants_search = not skip_tools and self._wants_web_search(prompt) | |
| _wants_hug = not skip_tools and self._wants_hugin(prompt) | |
| # ๐ ๏ธ ์ฝ๋ฉ ์์ด์ ํธ ๋ชจ๋ โ Imperial Coding Protocol์ด ์์คํ ์ง์์ ์ฃผ์ ๋ ๊ฒฝ์ฐ | |
| # (๋ก์ปฌ ํ๋ฏธ๋ 'ํ๋ฏธ๋ ์ฝ๋' cwd ์ง์ ๋ฑ) code_* ๋๊ตฌ 7์ข ์ ๋ถ์ฐฉํ๋ค. | |
| # ์ผ๋ฐ ์ก๋ด์ '์ฝ๋' ๋จ์ด๊ฐ ์ค์ณค๋ค๊ณ ๋ถ์ด๋ฉด ์ฝํ ๋ก์ปฌ ๋ชจ๋ธ์ด ๊ณผ๋ถํ๋๋ฏ๋ก, | |
| # ํ๋กํ ์ฝ ๋ง์ปค๊ฐ ๋ช ์๋ ์์ฒญ์๋ง ํ์ ํ๋ค. | |
| _wants_code = not skip_tools and ( | |
| "Imperial Coding Protocol" in (system_instruction or "") | |
| ) | |
| _wants_del = not skip_tools and self._wants_delegate(prompt) | |
| if _wants_del: | |
| # ๐ค [์์ ๊ฐ์ ์ฌ์ด] ์ฝํ ๋ก์ปฌ ๋ชจ๋ธ์ '์์ํด์ค'๋ฅผ ๋ฌด์ํ๊ณ ์ง์ code_* ๋ก | |
| # ์ํํด๋ฒ๋ฆฐ๋ค(์ค๊ด์ธก) โ ์์ ๋ช ์ ์์ฒญ์์๋ delegate ๋๊ตฌ ์ฌ์ฉ์ ์์คํ | |
| # ์ง์๋ก ๋ชป๋ฐ๋๋ค. | |
| messages.append({ | |
| "role": "system", | |
| "content": ("์ฌ์ฉ์๊ฐ ํน์ ๋๋ฃ ์ํธ์(์ฝ๋ฑ์ค/๋ฏธ๋๋งฅ์ค/ํค๋ฅด๋ฉ์ค/์๋น์ค/LM Studio)์๊ฒ " | |
| "์์ '์์'์ ๋ช ์์ ์ผ๋ก ์์ฒญํ๋ค. ๊ทธ ์์ ์ ๋ค๊ฐ ์ง์ ์ํํ์ง ๋ง๊ณ , " | |
| "๋ฐ๋์ delegate_to_guardian(guardian, task) ๋๊ตฌ๋ฅผ ํธ์ถํด ์์ํ๋ผ. " | |
| "task์๋ ๋ํ๋ฅผ ๋ชป ๋ณด๋ ์ํธ์๋ ์ดํดํ ์๊ฒฐ๋ ์ง์๋ฌธ์ ๋ด์๋ผ. " | |
| "๋๊ตฌ๊ฐ ๋๋ ค์ค ์ํ ๊ฒฐ๊ณผ๋ฅผ ๊ทธ๋๋ก ์ ์งํ๊ฒ ๋ณด๊ณ ํ๋ผ."), | |
| }) | |
| if _wants_code: | |
| from .coding_tools import OPENAI_SCHEMAS as _CODE_SCHEMAS | |
| ollama_tools = list(_CODE_SCHEMAS) | |
| if _wants_del: | |
| # ๐ค ์ฝ๋ฉ ์ฝ์์์ "์ฝ๋ฑ์ค์๊ฒ ์์ผ์ค" โ ์์ ๋๊ตฌ๋ฅผ ํจ๊ป ๋ถ์ฐฉ | |
| ollama_tools += self.runtime.curated_native_schemas( | |
| {"only_tools": ["delegate_to_guardian"]}) | |
| elif _wants_obs and _wants_search: | |
| # ๋ณตํฉ ์๋: ๊ฒ์ ํ ์ต์๋์ธ ์ ์ฅ โ 2๊ฐ ๋๊ตฌ | |
| ollama_tools = self.runtime.curated_native_schemas({"obsidian_with_search": True}) | |
| elif _wants_obs: | |
| # ์ต์๋์ธ๋ง: 1๊ฐ ๋๊ตฌ | |
| ollama_tools = self.runtime.curated_native_schemas({"obsidian_only": True}) | |
| elif _wants_hug: | |
| # ๋ธ๋ผ์ฐ์ : hugin ๋๊ตฌ๋ง | |
| ollama_tools = self.runtime.curated_native_schemas({"hugin": True}) | |
| elif not skip_tools and _wants_med: | |
| # ๐ผ๏ธ/๐ง ๋ฏธ๋์ดยท3Dยท์ ํ๋ธ ๊ฒ์: ์ ์ฑ ์ด ์ ํ ๋๊ตฌ ๋ฌถ์๋ง (gemma4 ํ๊ฐ ๋ต๋ณ ๋ฐฉ์ง) | |
| ollama_tools = self.runtime.curated_native_schemas( | |
| {"only_tools": self._intent_cfg("media").get("tools")}) | |
| elif _wants_del: | |
| # ๐ค ์์ ์๋๋ง: delegate ๋๊ตฌ 1์ข (์ฝํ ๋ชจ๋ธ ๊ณผ๋ถํ ๋ฐฉ์ง) | |
| ollama_tools = self.runtime.curated_native_schemas( | |
| {"only_tools": self._intent_cfg("delegate").get("tools")}) | |
| else: | |
| ollama_tools = None | |
| host = (getattr(self, "ollama_host", None) or "http://127.0.0.1:11434").rstrip("/") | |
| if host.startswith("0.0.0.0"): | |
| host = host.replace("0.0.0.0", "127.0.0.1") | |
| if not host.startswith("http://") and not host.startswith("https://"): | |
| host = f"http://{host}" | |
| if ":" not in host[8:]: | |
| host = f"{host}:11434" | |
| model = model_name or self.primary_model | |
| # ๐ฆ [๋ชจ๋ธ ํ๊ทธ ๋ณด์ ] Ollama๋ '์ ํํ ์ค์น ํ๊ทธ'๋ง ๋ฐ๋๋ค. ํ๊ทธ๋ฅผ ๋บ 'gemma4'๋ | |
| # 'gemma4:latest'๋ก ํด์๋๋๋ฐ ๊ทธ ํ๊ทธ๊ฐ ์์ผ๋ฉด 404("model not found")๊ฐ ๋๊ณ , | |
| # provider๋ง ollama๋ก ๋ฐ๋๊ณ ๋ชจ๋ธ์ ๋ค๋ฅธ ํ๋ก๋ฐ์ด๋ ๊ฐ(์: 'gemini-3.1-flash-lite')์ด | |
| # ๊ทธ๋๋ก ๋์ด์๋ 404๋ค. ๋ฆด๋ ์ด(_resolve_installed_model)์ ๋์ผํ๊ฒ ์ค์น ๋ชฉ๋ก๊ณผ | |
| # ๋์กฐํด, ์๊ฑฐ๋ ํ๊ทธ๊ฐ ์ด๊ธ๋๋ฉด ์ฒซ ์ค์น ๋ชจ๋ธ๋ก ํด๋ฐฑํ๋ค โ 404 ๋์ ์๋ ์ ํฉ. | |
| try: | |
| from blueprints.relay_common import _resolve_installed_model | |
| _resolved_model = _resolve_installed_model( | |
| host, model, "/api/tags", "models", "name", model | |
| ) | |
| if _resolved_model and _resolved_model != model: | |
| logger.info( | |
| f"[Ollama] ๋ชจ๋ธ ํ๊ทธ ๋ณด์ : '{model}' โ '{_resolved_model}' (์ค์น ๋ชฉ๋ก ๊ธฐ์ค)" | |
| ) | |
| model = _resolved_model | |
| except Exception as _resolve_err: | |
| logger.debug(f"[Ollama] ๋ชจ๋ธ ํ๊ทธ ๋ณด์ ์๋ต: {_resolve_err}") | |
| # [๐๏ธ Guardian Job] ์ง์ (capabilities)์ num_ctx๊ฐ ์ง์ ๋๋ฉด ์ต์ฐ์ ์ ์ฉ. | |
| # ๋ฏธ์ง์ (0/None)์ด๋ฉด ๊ฒ์ ์ปจํ ์คํธ ์ ๋ฌด์ ๋ฐ๋ผ ํฉ๋ฆฌ์ ๊ธฐ๋ณธ๊ฐ(8k/4k)์ ์ด๋ค. | |
| try: | |
| _cfg_ctx = int((self.dynamic_config or {}).get("num_ctx") or 0) | |
| except Exception: | |
| _cfg_ctx = 0 | |
| try: | |
| _num_ctx = _cfg_ctx or int(getattr(self, "ollama_num_ctx", 0) or (8192 if search_context else 4096)) | |
| except Exception: | |
| _num_ctx = 4096 | |
| # ๐ ๏ธ ์ฝ๋ฉ ์์ด์ ํธ ๋ชจ๋: ํ์ผ ๋ด์ฉ + ๋๊ตฌ ๊ฒฐ๊ณผ๊ฐ ์ปจํ ์คํธ๋ฅผ ํฌ๊ฒ ์ฐจ์งํ๋ฏ๋ก | |
| # ์ต์ 16k๋ฅผ ๋ณด์ฅํ๋ค (4k๋ฉด ์์คํ ํ๋กฌํํธ+์ฝ๋ ๋ช ์ค์ ๋ฐ๋ก ์๋ฆผ). | |
| if _wants_code: | |
| _num_ctx = max(_num_ctx, 16384) | |
| # [๐๏ธ Guardian Job] num_predict = ์๋ต ํ ํฐ ์ํ. 0/๋ฏธ์ง์ ์ด๋ฉด ๋ฌด์ ํ(-1). | |
| # gemma ๋ฑ์ด ์งง์ ์ธ์ฌ์๋ ์๋ฐฑ~์์ฒ ํ ํฐ์ ์์ฑํด ๋๋ ค์ง๋ ๋ฌธ์ ๋ฅผ ์ง์ ๋ณ๋ก | |
| # ์บกํ๋ค. ์ค์ธก: ์์ฑ ์๋๋ ๊ณ ์ (~tok/s)์ด๋ผ ํ ํฐ ์๊ฐ ๊ณง ์๋ต ์๊ฐ์ด๋ค. | |
| try: | |
| _num_predict = int((self.dynamic_config or {}).get("num_predict") or 0) | |
| except Exception: | |
| _num_predict = 0 | |
| _oplap("messages+tools-build") | |
| logger.info( | |
| f"[Ollama] model={model} messages_count={len(messages)} " | |
| f"has_search_ctx={bool(search_context)} num_ctx={_num_ctx} " | |
| f"num_predict={_num_predict or 'โ'} has_tools={bool(ollama_tools)}" | |
| ) | |
| try: | |
| max_iterations = self.dynamic_config.get("max_iterations", 15) | |
| ctx = RuntimeCtx(prompt, interactive=True, debug=_is_debug_mode(), display_name="Ollama") | |
| ctx.expect_obsidian_write = self._wants_obsidian(prompt) and self._wants_obsidian_write(prompt) | |
| # ๐ [์ฝ๋ฉ ๋ชจ๋] ์ค๊ฐ ํด(๋๊ตฌ ํธ์ถ ์ ๊ณํ ๋ฐํ, ์ฌ์ด๋นํ ํด)์ ์คํธ๋ฆผ์ ๋ด๋ณด๋ด์ง | |
| # ์๋๋ค โ ์ฌ์ด ๋ฃจํ๊ฐ ๋ ๋๋ง๋ค ์ฅํฉํ ๋ฌธ๋จ์ด ์๋ต์ ๋์ ๋๋ ๋ฌธ์ ์ฐจ๋จ. | |
| # ์ต์ข ํด์ handle_no_tool_calls์ flush๊ฐ ํ ๋ฒ๋ง ๋ฐฉ์ถํ๋ค. | |
| if _wants_code and not stream_tokens: | |
| # ๐ stream_tokens(ํ๋ฏธ๋ CLI send-stream)์์๋ ์ต๋ฅํ์ง ์๋๋ค โ | |
| # ์๋น์ค์ฒ๋ผ ๋๊ตฌ ํธ์ถ ์ฌ์ด ๋๋ ์ด์ ๊ณผ ํ ํฐ์ด ์ค์๊ฐ์ผ๋ก ํ๋ฅด๊ฒ. | |
| ctx.suppress_stream = True | |
| for _ in range(max_iterations): | |
| _opts = {"num_ctx": _num_ctx} | |
| if _num_predict > 0: | |
| _opts["num_predict"] = _num_predict | |
| json_data = { | |
| "model": model, | |
| "messages": messages, | |
| "stream": True, | |
| "options": _opts, | |
| # ๐ฆ ๋ชจ๋ธ์ VRAM์ 30๋ถ ์์ฃผ์์ผ ๋งค ์์ฒญ ์ฝ๋ ๋ก๋ฉ(์ค์ธก load=7.3s)์ ์ ๊ฑฐ. | |
| # ๋ก์ปฌ ์ฑ๋์ด ๋น ๋ฅธ ์ด์ ์ค ํ๋๊ฐ ๋ชจ๋ธ์ด ๋ฐ๋ปํ๊ฒ ๋ ์๊ธฐ ๋๋ฌธ. | |
| "keep_alive": (os.environ.get("OLLAMA_KEEP_ALIVE") or "30m"), | |
| # ๐ง [Thinking OFF] gemma4 ๋ฑ ์ถ๋ก (thinking) ๋ชจ๋ธ์ ๋ต๋ณ ์ ์ ์๋ฐฑ ํ ํฐ์ | |
| # ์ถ๋ก ์ ์ฐ๋๋ฐ, ๊ทธ ํ ํฐ์ content๊ฐ ์๋๋ผ message.thinking์ผ๋ก ๊ฐ ๋ฒ๋ ค์ง๋ค. | |
| # โ ์๊ฐ๋ง ์ก์๋จน๊ณ (์ค์ธก ๋ต๋ณ 4.3s vs 12s+), num_predict๊ฐ ๋ฎ์ผ๋ฉด ์ถ๋ก ๋จ๊ณ์์ | |
| # ์๋ ค ๋ต์ด ๋น๋ค(content_len=0). ์ฑํ ์๋ ์ฐ์ ์ด๋ฏ๋ก thinking์ ๋๋ค. | |
| # ๋น์ถ๋ก ๋ชจ๋ธ์ ์ด ํ๋๋ฅผ ๋ฌด์ํ๋ค. OLLAMA_THINK=1๋ก ์ฌํ์ฑํ ๊ฐ๋ฅ. | |
| "think": (os.environ.get("OLLAMA_THINK", "").lower() in ("1", "true", "yes")), | |
| } | |
| if ollama_tools and not ctx.force_no_tools: | |
| json_data["tools"] = ollama_tools | |
| _ollama_post_at = time.time() | |
| logger.info(f"โฑ๏ธ [Ollama] POST /api/chat ์์ (์ง์ ํ {(time.time() - _op_t0)*1000:.0f}ms, msgs={len(messages)}, sys_len={sum(len(m.get('content') or '') for m in messages if m.get('role')=='system')})") | |
| response = requests.post( | |
| f"{host}/api/chat", | |
| json=json_data, | |
| stream=True, | |
| timeout=180, | |
| ) | |
| response.raise_for_status() | |
| full_content = "" | |
| tool_calls = [] | |
| first_yield = True | |
| _usage_acc = None # ๐ช ํ ํฐ ์ฌ์ฉ๋(done ์ฒญํฌ) | |
| # ๐ก๏ธ [๋ฐ์ด์จ์ด ๊ฐ๋] ์ผ๋ถ ๊นจ์ง ๋ชจ๋ธ ํ๊ทธ(์: gemma4:12b-mlx)๋ EOS ๋์ | |
| # ํน์ํ ํฐ <pad>/<eos>/<unusedNN>์ ๋ฌดํ ์์ฑํ๋ค. num_predict ๋ฌด์ ํ์ด๋ฉด | |
| # 180s ํ์์์๊น์ง ๋งค๋ฌ๋ฆฐ๋ค โ ์ฐ์ ํน์ํ ํฐ์ด ์๊ณ ์ด๊ณผ๋ฉด ์กฐ๊ธฐ ์ค๋จยท๊ด์ธก. | |
| _junk_run = 0 | |
| _runaway_hit = False | |
| _JUNK_RE = re.compile(r"<pad>|<eos>|<unused\d*>") | |
| def _is_junk(txt: str) -> bool: | |
| # ์๋ฌธ์ ๋น์ด์์ง ์์๋ฐ, ํน์ํ ํฐ(<pad>/<eos>/<unusedNN>)์ ๋ค ์ง์ฐ๋ฉด | |
| # ์๋ฌด๊ฒ๋ ์ ๋จ๋ ์ฒญํฌ = ๋ฐ์ด์จ์ด์ฑ ์ฐ๋ ๊ธฐ ์ถ๋ ฅ. | |
| if not (txt or "").strip(): | |
| return False | |
| return _JUNK_RE.sub("", txt).strip() == "" | |
| for line in response.iter_lines(): | |
| if not line: | |
| continue | |
| try: | |
| data = json.loads(line.decode("utf-8")) | |
| except Exception: | |
| continue | |
| message_chunk = data.get("message", {}) | |
| content = message_chunk.get("content", "") | |
| if content: | |
| # ํน์ํ ํฐ๋ง์ผ๋ก ๋ ์ฒญํฌ๊ฐ ์ฐ์๋๋ฉด ๋ฐ์ด์จ์ด๋ก ํ์ | |
| if _is_junk(content): | |
| _junk_run += 1 | |
| if _junk_run >= 12: | |
| _runaway_hit = True | |
| logger.error("[Ollama] ๋ฐ์ด์จ์ด ๊ฐ์ง(<pad> ๋ฑ ํน์ํ ํฐ ๋ฐ๋ณต) โ ๋ชจ๋ธ=%s ์ค๋จ", model) | |
| self._observe( | |
| 'model_runaway', | |
| f"{model}์ด ํน์ํ ํฐ(<pad> ๋ฑ)์ ๋ฐ๋ณต ์์ฑ โ ์กฐ๊ธฐ ์ค๋จ", | |
| severity='error', | |
| meta={'model': model, 'junk_run': _junk_run, 'chars': len(full_content)}, | |
| aegis_url=aegis_url, token=token) | |
| try: | |
| response.close() | |
| except Exception: | |
| pass | |
| break | |
| continue # junk ์ฒญํฌ๋ ๋์ /๋ฐฉ์ถํ์ง ์์ | |
| _junk_run = 0 | |
| full_content += content | |
| if first_yield: | |
| first_yield = False | |
| logger.info(f"โฑ๏ธ [Ollama] ์ฒซ ์ฝํ ์ธ ์ฒญํฌ โ prompt eval {(time.time() - _ollama_post_at)*1000:.0f}ms") | |
| if not ctx.suppress_stream: | |
| yield content | |
| tc_chunk = message_chunk.get("tool_calls", []) | |
| for tc in tc_chunk: | |
| # Ollama tool call formats: {"function": {"name": "...", "arguments": {...}}} | |
| fn = tc.get("function") or {} | |
| name = fn.get("name", "") | |
| args = fn.get("arguments", "") | |
| if isinstance(args, dict): | |
| args = json.dumps(args, ensure_ascii=False) | |
| tc_id = tc.get("id") or f"call_ollama_{len(tool_calls)}" | |
| # ์ค๋ณต ์์ง ๋ฐฉ์ง (Ollama ์คํธ๋ฆผ ์ฒญํฌ ๋์ ๋์) | |
| if not any(x["id"] == tc_id for x in tool_calls): | |
| tool_calls.append({ | |
| "id": tc_id, | |
| "type": "function", | |
| "function": { | |
| "name": name, | |
| "arguments": args or "{}", | |
| } | |
| }) | |
| if data.get("done") is True: | |
| _usage_acc = { | |
| "prompt_tokens": data.get("prompt_eval_count"), | |
| "completion_tokens": data.get("eval_count"), | |
| } | |
| # ๐ฆ [Ollama ์๊ฐ ๋ถํด] load(๋ชจ๋ธ ๋ก๋ฉ)/prompt_eval(์ ๋ ฅ ์ฒ๋ฆฌ)/eval(ํ ํฐ ์์ฑ) | |
| # ์ค์ ์์์๊ฐ(ns)์ ์ด๋ก ํ์ฐํด ์ง๋จ ๋ง์ปค๋ก ๋ ธ์ถ. ์ฝ๋ ๋ก๋ฉ vs ์ ๋ ฅ ์ฒ๋ฆฌ vs | |
| # ํ ํฐ ์์ฑ ์ค ๋ฌด์์ด ๋ณ๋ชฉ์ธ์ง ํ๋์ ๊ฐ๋ฆฐ๋ค. stage_timing ์ผ์ก์ ๋๋ง. | |
| try: | |
| _ld = (data.get("load_duration") or 0) / 1e9 | |
| _pe = (data.get("prompt_eval_duration") or 0) / 1e9 | |
| _ev = (data.get("eval_duration") or 0) / 1e9 | |
| _pc = data.get("prompt_eval_count") or 0 | |
| _ec = data.get("eval_count") or 0 | |
| logger.info( | |
| f"[Ollama ์๊ฐ๋ถํด] model={model} load={_ld:.2f}s " | |
| f"prompt_eval={_pe:.2f}s({_pc}tok) eval={_ev:.2f}s({_ec}tok) " | |
| f"full_content_len={len(full_content)} suppress={getattr(ctx, 'suppress_stream', None)} " | |
| f"content_preview={full_content[:60]!r}" | |
| ) | |
| if (self.dynamic_config or {}).get("stage_timing", True): | |
| # โ ๏ธ ๋ฐ๋์ '[STAGE:'๋ก ์์ํด์ผ ํ๋ค(์์ \n ๋ถ์ด๋ฉด ์์ ์ธก | |
| # startswith ๊ฒ์ฌ๋ฅผ ํต๊ณผ ๋ชปํด ์๋ต ๋ณธ๋ฌธ์ ์์ฌ ์ค์ผ๋จ). | |
| yield ( | |
| f"[STAGE:๐ฆ ๋ก๋ {_ld:.1f}s ยท ์ ๋ ฅ {_pe:.1f}s({_pc}tok) ยท " | |
| f"์์ฑ {_ev:.1f}s({_ec}tok)|0]" | |
| ) | |
| except Exception: | |
| pass | |
| break | |
| # ๐ก๏ธ ๋ฐ์ด์จ์ด๋ก ์ค๋จ๋๊ณ ์ค์ ๋ณธ๋ฌธ์ด ์์ผ๋ฉด, ๋น '์๊ฐ์ค' ๋์ ์๋ด๋ฅผ ๋ด๋ณด๋ธ๋ค. | |
| if _runaway_hit and not full_content.strip() and not tool_calls: | |
| yield (f"โ ๏ธ ์ ํ๋ ๋ชจ๋ธ(`{model}`)์ด ๋น์ ์ ์ถ๋ ฅ(<pad> ๋ฑ ํน์ํ ํฐ ๋ฐ๋ณต)์ ๋์ต๋๋ค. " | |
| f"์ด ๋ชจ๋ธ ํ๊ทธ๊ฐ ๊นจ์ง ๊ฒ์ผ๋ก ๋ณด์ ๋๋ค โ ๋ค๋ฅธ ๋ชจ๋ธ(์: gemma4:31b-mlx)๋ก ๋ฐ๊ฟ ์ฃผ์ธ์.") | |
| return | |
| self._emit_token_usage("ollama", model, _usage_acc) | |
| if not tool_calls: | |
| # ๐ ๏ธ [์ฝ๋ฉ ์ฌ์ด] โ ๋ง๋ก๋ง ๊ณํํ๊ณ ๋๊ตฌ๋ฅผ ์ ๋ถ๋ฅด๋ ๊ฒฝ์ฐ, | |
| # โก 'code_edit_file(...)' ๊ฐ์ ๋๊ตฌ ํธ์ถ์ ํ ์คํธ๋ก ํ๋ด๋ง ๋ธ ๊ฒฝ์ฐ(๊ฐ์ง ํธ์ถ), | |
| # โข ๋๊ตฌ๋ฅผ ์ฐ๋ค๊ฐ "์์ ํ๊ฒ ์ต๋๋ค" ๊ฐ์ ์ฝ์๋ง ํ๊ณ ๋ฉ์ถ ๊ฒฝ์ฐ(์ฝ์-๋ฏธ์ดํ) | |
| # ์ค์ code_* ํธ์ถ์ ์ฌ์ดํ๋ค (์ต์๋์ธ nudge์ ๋์ผ ํจํด, ์ต๋ 3ํ). | |
| # '์๋ฃ' ๋ณด๊ณ ๊ฐ ํฌํจ๋ ํ ์คํธ๋ ์ต์ข ๋ณด๊ณ ๋ก ๋ณด๊ณ ์ฌ์ดํ์ง ์๋๋ค. | |
| _fake_code_call = _wants_code and bool(re.search( | |
| r"code_(?:list_dir|glob|grep|read_file|write_file|edit_file|run_command)\s*\(", | |
| full_content or "", | |
| )) | |
| _code_promise = _wants_code and "์๋ฃ" not in (full_content or "") and any( | |
| ph in (full_content or "") for ph in ( | |
| "ํ๊ฒ ์ต๋๋ค", "ํ ๊ฒ์", "์์ ํ๊ฒ ", "์งํํ๊ฒ ", "์คํํ๊ฒ ", | |
| "๊ฒ์ฆํ๊ฒ ", "๋ถ์ํ๊ฒ ", "์์ํ๊ฒ ", "๊ณ ์น๊ฒ ", "์์ฑํ๊ฒ ", | |
| ) | |
| ) | |
| if (_wants_code and full_content.strip() | |
| and getattr(ctx, "coding_nudges", 0) < 3 | |
| and (not ctx.used_tools or _fake_code_call or _code_promise)): | |
| ctx.coding_nudges = getattr(ctx, "coding_nudges", 0) + 1 | |
| messages.append({"role": "assistant", "content": full_content}) | |
| if _fake_code_call: | |
| _nudge_msg = ( | |
| "๋ฐฉ๊ธ ์ถ๋ ฅํ 'code_...(...)' ํ๊ธฐ๋ ํ ์คํธ์ผ ๋ฟ์ด๋ฉฐ ์๋ฌด๊ฒ๋ " | |
| "์คํ๋์ง ์์๋ค. ๋ฐ๋์ ์ค์ ํจ์ ํธ์ถ(tool call)๋ก ๋ค์ ์คํํ๋ผ. " | |
| "ํธ์ถ ์ ์๋ ์ ๋ '์๋ฃ'๋ผ๊ณ ๋งํ์ง ๋ง๋ผ." | |
| ) | |
| elif _code_promise and ctx.used_tools: | |
| _nudge_msg = ( | |
| "'ํ๊ฒ ์ต๋๋ค'๋ผ๊ณ ์ฝ์๋ง ํ๊ณ ๋ฉ์ถ์ง ๋ง๋ผ. ๋งํ ์์ ์ ์ง๊ธ ์ฆ์ " | |
| "code_edit_file / code_run_command ๋ฑ ์ค์ ๋๊ตฌ ํธ์ถ๋ก ์ํํ๊ณ , " | |
| "๊ฒ์ฆ๊น์ง ๋๋ ๋ค์ ์ต์ข ๋ณด๊ณ ์ taemini-todo ๋ธ๋ก์ ์ถ๋ ฅํ๋ผ." | |
| ) | |
| else: | |
| _nudge_msg = ( | |
| "๋ง๋ก๋ง ๊ณํํ์ง ๋ง๊ณ ์ง๊ธ ์ฆ์ code_read_file / code_grep / " | |
| "code_list_dir ๋ฑ code_* ๋๊ตฌ๋ฅผ ์ค์ ๋ก ํธ์ถํด ์์ ์ ์์ํ๋ผ. " | |
| "๋๊ตฌ๋ฅผ ํธ์ถํ์ง ์์ผ๋ฉด ํ์ผ์ ์ฝํ์ง๋ ์์ ๋์ง๋ ์๋๋ค. " | |
| "์ด๋ฏธ ์์ ์ ๋๋๊ณ ๊ฒ์ฆ๊น์ง ํต๊ณผํ๋ค๋ฉด ์ต์ข ๋ณด๊ณ ์ taemini-todo " | |
| "๋ธ๋ก๋ง ์ถ๋ ฅํ๋ผ." | |
| ) | |
| messages.append({"role": "user", "content": _nudge_msg}) | |
| logger.info( | |
| f"๐ ๏ธ [Coding Nudge] " | |
| f"{'๊ฐ์ง ํ ์คํธ ํธ์ถ' if _fake_code_call else ('์ฝ์-๋ฏธ์ดํ' if _code_promise and ctx.used_tools else '๋๊ตฌ ๋ฏธํธ์ถ')} " | |
| f"๊ฐ์ง โ code_* ๊ฐ์ ์ฌ์ด ({ctx.coding_nudges}/3)" | |
| ) | |
| continue | |
| # [๐๏ธ obsidian ์ง์ ํด๋ฐฑ] tool_calls ์๋๋ฐ obsidian ์๋์ด๋ฉด | |
| # ํ ์คํธ ์๋ต์์ ์ง์ ๋ ธํธ ์์ฑ ์คํ (๋ชจ๋ธ์ด tool calling ๋ชป ํ ๋) | |
| if ctx.expect_obsidian_write and not ctx.obsidian_written and full_content.strip(): | |
| _obsidian_result = yield from self._ollama_direct_obsidian_write(prompt, full_content, ctx) | |
| if _obsidian_result == "continue": | |
| continue | |
| break | |
| # ๐ก๏ธ [Tool Runtime] ๋๊ตฌ ์๋ ์๋ต โ ๊ตฌ์ ์ฒ๋ฆฌ ์ผ์ํ | |
| _action = yield from self.runtime.handle_no_tool_calls(full_content, messages, ctx) | |
| if _action == "continue": | |
| continue | |
| # ๐๏ธ obsidian ์ฌ์ด (์ฝ์๋ง ํ๊ณ ๋๊ตฌ ๋ฏธํธ์ถ) | |
| _nudge = self.runtime.nudge_obsidian_write(full_content, messages, ctx) | |
| if _nudge == "continue": | |
| continue | |
| break | |
| # ๋๊ตฌ ํธ์ถ ์คํ | |
| _n_before = len(messages) | |
| yield from self.runtime.run_tool_calls(tool_calls, full_content, messages, ctx) | |
| if _wants_code: | |
| # ๐ ๏ธ [์ฝ๋ฉ ์์ด์ ํธ ๋ฃจํ] ์ฝ๊ธฐโ์์ โ๊ฒ์ฆโ๋ฐ๋ณต์ด ๊ฐ๋ฅํ๋๋ก ๋ฃจํ๋ฅผ ๊ณ์ํ๋ค. | |
| # gemma4 ๋ฑ์ด tool role ๋ฉ์์ง ํฌํจ follow-up์ 400์ผ๋ก ๊ฑฐ๋ถํ๋ฏ๋ก, | |
| # ๋ฐฉ๊ธ ๋ถ์ OpenAI์ tool ๋ฉ์์ง๋ฅผ ์ผ๋ฐ ํ ์คํธ ํด(assistant/user)์ผ๋ก | |
| # ๋ณํํด ์ด๋ค ๋ก์ปฌ ๋ชจ๋ธ์์๋ ํ์ ์ถ๋ก ์ด ๊ฐ๋ฅํ๊ฒ ํ๋ค. | |
| # โ ๏ธ assistant ํด์ '[๋๊ตฌ ํธ์ถ: ...]' ๊ฐ์ ํ ์คํธ ํ๊ธฐ๋ฅผ ๋จ๊ธฐ๋ฉด ์ฝํ | |
| # ๋ชจ๋ธ์ด ๊ทธ ํฌ๋งท์ ํ๋ด ๋ด '๊ฐ์ง(ํ ์คํธ) ๋๊ตฌ ํธ์ถ'์ ์ถ๋ ฅํ๋ค โ ๋๊ตฌ | |
| # ์ด๋ฆ/์ธ์๋ user ํด์ ๊ฒฐ๊ณผ ๋ธ๋ก์๋ง ๋จ๊ธด๋ค. | |
| _appended = messages[_n_before:] | |
| del messages[_n_before:] | |
| _tool_lines = [] | |
| for _m in _appended: | |
| if _m.get("role") == "assistant": | |
| _atext = str(_m.get("content") or "").strip() | |
| if _atext: | |
| messages.append({"role": "assistant", "content": _atext}) | |
| elif _m.get("role") == "tool": | |
| _tool_lines.append( | |
| f"### {_m.get('name')} ๊ฒฐ๊ณผ\n{str(_m.get('content', ''))[:6000]}" | |
| ) | |
| if _tool_lines: | |
| messages.append({ | |
| "role": "user", | |
| "content": ( | |
| "[๋๊ตฌ ์คํ ๊ฒฐ๊ณผ]\n" + "\n\n".join(_tool_lines) + | |
| "\n\n์ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ์ํด ๋ค์ ๋จ๊ณ๋ฅผ ๊ณ์ ์งํํ๋ผ (ํ์ํ๋ฉด code_* " | |
| "๋๊ตฌ๋ฅผ ๋ค์ ํธ์ถ). ๋ชจ๋ ์์ ๊ณผ ๊ฒ์ฆ(์คํ/ํ ์คํธ)์ด ๋๋ฌ์ ๋๋ง " | |
| "์ต์ข ๋ณด๊ณ ์ taemini-todo ๋ธ๋ก์ ์ถ๋ ฅํ๊ณ ๋ง์ณ๋ผ." | |
| ), | |
| }) | |
| continue | |
| # [๐ฑ Ollama ์ ์ฉ] ๋๊ตฌ ์คํ ์๋ฃ ํ ๋ฃจํ ์ข ๋ฃ | |
| # gemma4 ๋ฑ์ด tool role ๋ฉ์์ง ํฌํจ follow-up์ 400์ผ๋ก ๊ฑฐ๋ถํ๋ฏ๋ก | |
| # ๋๊ตฌ๋ง ์คํํ๊ณ ๋ฐ๋ก ์ข ๋ฃํ๋ค (์์ฝ ์์ด๋ ์ถฉ๋ถ) | |
| break | |
| except requests.exceptions.HTTPError as http_err: | |
| _status = getattr(getattr(http_err, "response", None), "status_code", 0) | |
| if _status == 400 and ctx.used_tools: | |
| # [โ ์ ์] ๋๊ตฌ ์คํ ํ follow-up 400 โ ์ด๋ฏธ ์๋ฃ๋จ | |
| logger.info("[Ollama] ๋๊ตฌ ์คํ ํ follow-up 400 (์ ์ ๋์) โ ์ข ๋ฃ") | |
| elif _status == 400: | |
| # [โ ๏ธ tools ๋ฏธ์ง์] ๋๊ตฌ ์์ด ์ฌ์๋ | |
| logger.warning("[Ollama] 400 Bad Request โ tools ๋ฏธ์ง์ ๋ชจ๋ธ. ๋๊ตฌ ์์ด ์ฌ์๋...") | |
| yield from self._think_ollama( | |
| prompt, model_name, history=history, | |
| system_instruction=system_instruction, images=images, | |
| token=token, aegis_url=aegis_url, skip_tools=True, | |
| skip_grounding=skip_grounding, | |
| ) | |
| elif _status == 404: | |
| # [โ ๏ธ ๋ชจ๋ธ ์์] Ollama๋ '์ ํํ ์ค์น ํ๊ทธ'๋ง ๋ฐ๋๋ค. ์์ฒญ ๋ชจ๋ธ์ด | |
| # ์ค์น๋ผ ์์ง ์์ผ๋ฉด 404("model not found"). ์ค์น ๋ชฉ๋ก์ '์ง๊ธ ๋ค์' | |
| # ์กฐํํด ์ ํจํ ๋ชจ๋ธ๋ก '์๋ ์ฌ์๋'ํ๋ค โ ์ ์ ๊ฐ ์๋์ผ๋ก ๋๊ธฐํ/์ ํ | |
| # ํ์ง ์์๋ ๋๋ถ๋ถ ๊ทธ๋๋ก ์ฑ๊ณต. ์ฌ์กฐํํด๋ ๋ฐ๋ ๊ฒ ์์ผ๋ฉด(์ค์น ๋ชจ๋ธ์ด | |
| # ์์ ์์) ๋ฌดํ๋ฃจํ ์์ด ์๋ด๋ก ํด๋ฐฑํ๋ค. | |
| logger.warning(f"[Ollama] 404 ๋ชจ๋ธ ์์: ์์ฒญ='{model}' โ ๋ชฉ๋ก ์ฌ์กฐํ ํ ์๋ ์ฌ์๋") | |
| _retry_model = None | |
| try: | |
| from blueprints.relay_common import _resolve_installed_model | |
| _cand = _resolve_installed_model( | |
| host, model, "/api/tags", "models", "name", model | |
| ) | |
| if _cand and _cand != model: | |
| _retry_model = _cand | |
| except Exception as _re_err: | |
| logger.debug(f"[Ollama] 404 ์ฌ์กฐํ ์คํจ: {_re_err}") | |
| if _retry_model: | |
| logger.info(f"[Ollama] 404 ์๋ ๋ณต๊ตฌ: '{model}' โ '{_retry_model}' ์ฌ์๋") | |
| yield from self._think_ollama( | |
| prompt, _retry_model, history=history, | |
| system_instruction=system_instruction, images=images, | |
| token=token, aegis_url=aegis_url, skip_tools=skip_tools, | |
| tools=tools, skip_grounding=True, # ๊ทธ๋ผ์ด๋ฉ์ ์ฒซ ํจ์ค์์ ์๋ฃ โ ์ค๋ณต ๋ฐฉ์ง | |
| ) | |
| else: | |
| # ์ฌ์กฐํํด๋ ์ ํจ ๋ชจ๋ธ์ด ์์ = ์ค์น๋ ๋ชจ๋ธ์ด ์์ ์๋ ๊ฒฝ์ฐ โ ์๋ด. | |
| yield ( | |
| f"โ ๋ก์ปฌ Ollama์ ์ค์น๋ ๋ชจ๋ธ์ด ์์ด ์๋ตํ ์ ์์ต๋๋ค(์์ฒญ: `{model}`).\n\n" | |
| f"๐ ํฐ๋ฏธ๋์์ `ollama pull <๋ชจ๋ธ๋ช >` ์ผ๋ก ๋ชจ๋ธ์ ๋จผ์ ๋ฐ์ ๋ค, " | |
| f"**๋ธ๋ ์ธ ์ค์ โ '๐ ๋ก์ปฌ Ollama ๋ชจ๋ธ ๋๊ธฐํ'** ๋ฅผ ๋๋ฌ ๋ชฉ๋ก์ ๊ฐฑ์ ํด ์ฃผ์ธ์." | |
| ) | |
| else: | |
| logger.error(f"[Ollama] HTTP ์ค๋ฅ: {http_err}") | |
| yield f"โ ๋ก์ปฌ Ollama ํต์ ์ค๋ฅ: {http_err}" | |
| except Exception as e: | |
| logger.error(f"[Ollama] Local chat request failed: {e}") | |
| yield f"โ ๋ก์ปฌ Ollama ํต์ ์ค๋ฅ: {e}" | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # [๐๏ธ Ollama ํด๋ฐฑ] gemma4 ๋ฑ tool_calls ๋ฏธ์ง์ ๋ชจ๋ธ ์ ์ฉ | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| _GEMMA_JSON_TOOL_RE = re.compile( | |
| r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", re.IGNORECASE | |
| ) | |
| def _parse_gemma_tool_call(self, text: str): | |
| """gemma4๊ฐ ```json {...} ``` ํ์์ผ๋ก ์ถ๋ ฅํ๋ ๋๊ตฌ ํธ์ถ์ ํ์ฑํ๋ค. | |
| ์ฑ๊ณต ์ (func_name, args_dict) ๋ฐํ, ์คํจ ์ (None, None).""" | |
| for m in self._GEMMA_JSON_TOOL_RE.finditer(text): | |
| try: | |
| obj = json.loads(m.group(1)) | |
| # {"name": "obsidian_write", "parameters": {...}} ๋๋ | |
| # {"tool": "obsidian_write", "arguments": {...}} ๋ฑ ๋ค์ํ ํค ์ง์ | |
| name = obj.get("name") or obj.get("tool") or obj.get("function") or "" | |
| args = (obj.get("parameters") or obj.get("arguments") | |
| or obj.get("args") or {}) | |
| if name and isinstance(args, dict): | |
| return name, args | |
| except Exception: | |
| continue | |
| return None, None | |
| def _ollama_direct_obsidian_write(self, user_prompt: str, ai_response: str, ctx): | |
| """[๐๏ธ Ollama ํด๋ฐฑ] ๋ชจ๋ธ์ด tool_calls API๋ฅผ ๋ฐํ ๋ชปํ ๋ ์ง์ obsidian_write ์คํ. | |
| 1์์: gemma4 ```json ํด์ฝ ํ ์คํธ ํ์ฑ โ ์ค์ ์ธ์๋ก ์คํ | |
| 2์์: AI ํ ์คํธ ์๋ต์ ๊ทธ๋๋ก ๋ ธํธ ๋ด์ฉ์ผ๋ก ์ ์ฅ""" | |
| import threading | |
| # --- 1๋จ๊ณ: gemma4 ```json ํ์ฑ ์๋ --- | |
| parsed_name, parsed_args = self._parse_gemma_tool_call(ai_response) | |
| if parsed_name == "obsidian_write" and parsed_args: | |
| logger.info(f"[๐๏ธ Ollama] gemma4 ```json ํด์ฝ ํ์ฑ ์ฑ๊ณต โ {parsed_args}") | |
| write_args = parsed_args | |
| note_path = parsed_args.get("path") or parsed_args.get("title", "์ฌ๋ผ๋ง ๋ ธํธ") | |
| else: | |
| # --- 2๋จ๊ณ: ํ ์คํธ ์ง์ ์ ์ฅ (ํ์ฑ ์คํจ ์) --- | |
| # ์๋ต์ด ```json ... ``` ๋ฟ์ด๋ฉด ์ค์ ๋ด์ฉ ์์ โ ์ฌ์ฉ์ ํ๋กฌํํธ๋ก ์ ๋ชฉ ์ค์ | |
| clean_text = self._GEMMA_JSON_TOOL_RE.sub("", ai_response).strip() | |
| if not clean_text: | |
| # ๋ชจ๋ธ์ด JSON๋ง ๋ฑ๊ณ ์ค์ ๋ด์ฉ์ด ์์ โ ๊ธฐ๋ณธ ๋ด์ฉ ์์ฑ | |
| clean_text = f"# {user_prompt}\n\n(Ollama ์๋ ์์ฑ ๋ ธํธ)" | |
| note_path = user_prompt.strip()[:60].replace("/", "-").replace("\\", "-") | |
| write_args = {"path": note_path, "content": clean_text, "mode": "overwrite"} | |
| logger.info(f"[๐๏ธ Ollama Direct Obsidian] ์ ์ฅ ์๋: '{note_path}'") | |
| # --- 3๋จ๊ณ: ํ์์์ ์ค๋ ๋๋ก ๋ธ๋กํน ๋ฐฉ์ง (์ต๋ 10์ด) --- | |
| result_holder = [None] | |
| error_holder = [None] | |
| def _write(): | |
| try: | |
| result_holder[0] = self.runtime.dispatch("obsidian_write", write_args, ctx) | |
| except Exception as exc: | |
| error_holder[0] = str(exc) | |
| t = threading.Thread(target=_write, daemon=True) | |
| t.start() | |
| t.join(timeout=10) | |
| if t.is_alive(): | |
| logger.error("[๐๏ธ Ollama Direct Obsidian] ํ์์์(10s) โ Obsidian REST API ์๋ต ์์") | |
| yield f"\n\nโ ๏ธ Obsidian ์ ์ฅ ์๋ํ์ผ๋ ์๋ต ์์(10์ด ์ด๊ณผ). Obsidian์ด ์คํ ์ค์ธ์ง ํ์ธํด์ฃผ์ธ์." | |
| return "break" | |
| ctx.obsidian_written = True | |
| result = result_holder[0] or error_holder[0] or "์ ์ ์๋ ์ค๋ฅ" | |
| if error_holder[0] or "์คํจ" in str(result) or "Error" in str(result): | |
| logger.error(f"[๐๏ธ Ollama Direct Obsidian] ์ ์ฅ ์คํจ: {result}") | |
| yield f"\n\nโ ๏ธ Obsidian ์ ์ฅ ์คํจ: {result}" | |
| else: | |
| yield f"\n\nโ Obsidian์ ๋ ธํธ๋ฅผ ์ ์ฅํ์ต๋๋ค: **{note_path}**" | |
| return "break" | |
| def _ensure_hermes_proxy_running(self): | |
| """๐ชฝ hermes proxy(8645)๊ฐ ๊บผ์ ธ ์์ผ๋ฉด ๋ฐฑ๊ทธ๋ผ์ด๋๋ก ์๋ ๊ธฐ๋ํ๋ค.""" | |
| import socket | |
| try: | |
| with socket.create_connection(('127.0.0.1', 8645), timeout=1): | |
| return True | |
| except OSError: | |
| pass | |
| try: | |
| home = os.path.expanduser('~') | |
| exe = os.path.join(home, 'AppData', 'Local', 'hermes', 'hermes-agent', | |
| 'venv', 'Scripts', 'hermes.exe') | |
| if not os.path.exists(exe): | |
| logger.warning('[HermesProxy] hermes.exe ๋ฏธ์ค์น โ ํ๋ก์ ์๋ ๊ธฐ๋ ๋ถ๊ฐ') | |
| return False | |
| flags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0 | |
| subprocess.Popen([exe, 'proxy', 'start'], | |
| stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, | |
| creationflags=flags) | |
| logger.info('[HermesProxy] hermes proxy ์๋ ๊ธฐ๋ (ํฌํธ 8645)...') | |
| for _ in range(15): | |
| time.sleep(1) | |
| try: | |
| with socket.create_connection(('127.0.0.1', 8645), timeout=1): | |
| return True | |
| except OSError: | |
| continue | |
| logger.warning('[HermesProxy] 15์ด ๋ด ๊ธฐ๋ ํ์ธ ์คํจ') | |
| return False | |
| except Exception as exc: | |
| logger.warning('[HermesProxy] ์๋ ๊ธฐ๋ ์คํจ: %s', exc) | |
| return False | |
| def _think_openrouter( | |
| self, | |
| prompt: str, | |
| model_name: str, | |
| system_instruction: str = None, | |
| skip_tools: bool = False, | |
| tools: list = None, | |
| session_id: str = None, | |
| provider: str = "openrouter", | |
| interactive: bool = True, | |
| images: list = None | |
| ) -> Generator[str, None, None]: | |
| """OpenRouter ํ๋ก๋ฐ์ด๋ ๋ฐ OpenClaw ๋ฑ OpenAI ํธํ API๋ฅผ ํธ์ถํฉ๋๋ค.""" | |
| if provider == "openclaw": | |
| api_key = os.environ.get("OPENCLAW_GATEWAY_TOKEN", "imperial1234") | |
| base_url = "http://127.0.0.1:18789/v1" | |
| display_name = "OpenClaw" | |
| elif provider in ("nous", "hermes_proxy"): | |
| # ๐ชฝ [Hermes Proxy] OAuth ์ธ์ฆ๋ Nous Portal์ hermes proxy(8645)๊ฐ | |
| # OpenAI ํธํ์ผ๋ก ์ค๊ณ โ API ํค ๋ถํ์ (์๋ฌด Bearer๋ ํ์ฉ, ์ค์ ์๊ฒฉ์ฆ๋ช ์ ํ๋ก์๊ฐ ๋ถ์ฐฉ) | |
| api_key = "imperial-local" | |
| base_url = os.environ.get("HERMES_PROXY_URL", "http://127.0.0.1:8645").rstrip("/") + "/v1" | |
| display_name = "Nous Portal (Hermes Proxy)" | |
| self._ensure_hermes_proxy_running() | |
| elif provider == "relay": | |
| api_key = "imperial-local-bypass" | |
| base_url = "http://127.0.0.1:18710/api/v1" # Rust Brain for relay? or something else | |
| display_name = "Relay" | |
| elif provider == "nvidia": | |
| # [๐ฑ NVIDIA NIM] MiniMax-M3/M2.7 (OpenAI ํธํ, non-streaming) | |
| # M3ยทM2.7 ๋ชจ๋ ๋์ผ ํค๋ก ํตํฉ โ NVIDIA_MINIMAX_M3_API_KEY ์ฐ์ , ์์ผ๋ฉด NVIDIA_API_KEY | |
| api_key = self.nvidia_minimax_m3_api_key or self.nvidia_api_key | |
| base_url = "https://integrate.api.nvidia.com/v1" | |
| display_name = "NVIDIA NIM" | |
| if not api_key: | |
| yield "โ [NVIDIA] API Key๊ฐ ์์ต๋๋ค. Bitwarden์ 'Imperial:NVIDIA_MINIMAX_M3_API_KEY'๋ฅผ ๋ฑ๋กํด์ฃผ์ธ์." | |
| return | |
| elif provider == "glm": | |
| # [๐ฑ GLM / Z.ai] zai-org GLM-5.1/5.2 (modal.direct OpenAI ํธํ) | |
| api_key = self.glm_api_key | |
| base_url = "https://api.us-west-2.modal.direct/v1" | |
| display_name = "Z.ai GLM" | |
| if not api_key: | |
| yield "โ [GLM] API Key๊ฐ ์์ต๋๋ค. Bitwarden์ 'Imperial:GLM_API_KEY'๋ฅผ ๋ฑ๋กํด์ฃผ์ธ์." | |
| return | |
| else: | |
| api_key = self.openrouter_api_key | |
| base_url = "https://openrouter.ai/api/v1" | |
| display_name = "OpenRouter" | |
| if not api_key: | |
| yield "โ [OpenRouter] API Key๊ฐ ์์ต๋๋ค. Bitwarden์ 'Imperial:OPENROUTER_CLAUDE_TOKEN'์ ๋ฑ๋กํด์ฃผ์ธ์." | |
| return | |
| # ๐ผ๏ธ [Multimodal] ์ด๋ฏธ์ง๊ฐ ์ฒจ๋ถ๋๋ฉด OpenAI ํธํ ๋ฉํฐ๋ชจ๋ฌ ํ์์ผ๋ก user ๋ฉ์์ง๋ฅผ ๊ตฌ์ฑํ๋ค. | |
| # (์ ํํ ๋ชจ๋ธ์ด ๋น์ ์ง์์ด๋ฉด ์ด ์ด๋ฏธ์ง๋ฅผ ๋ณด๊ณ ๋ถ์ํ๋ค. ๋ฏธ์ง์ ๋ชจ๋ธ์ด๋ฉด ๋ฌด์/์๋ฌ) | |
| if images: | |
| _content = [{"type": "text", "text": prompt}] | |
| for _img in images: | |
| _data = _img.get("data") or "" | |
| if isinstance(_data, str) and "," in _data and _data.strip().startswith("data:"): | |
| _data = _data.split(",", 1)[1] | |
| _mime = _img.get("mimeType") or "image/jpeg" | |
| if _data: | |
| _content.append({ | |
| "type": "image_url", | |
| "image_url": {"url": f"data:{_mime};base64,{_data}"}, | |
| }) | |
| messages = [{"role": "user", "content": _content}] | |
| logger.info(f"๐ผ๏ธ [{display_name}] ๋ฉํฐ๋ชจ๋ฌ ์ ๋ ฅ โ ์ด๋ฏธ์ง {len(images)}์ฅ์ ๋ชจ๋ธ '{model_name}'์ ์ ๋ฌ.") | |
| else: | |
| messages = [{"role": "user", "content": prompt}] | |
| # [๐ฑ Defensive Instruction] OpenRouter ๋ชจ๋ธ๋ค์ด ์ฑํ ์๋ต ๋์ OS ์ ์ด ๋๊ตฌ๋ฅผ ๋จ์ฉํ๋ ๊ฒ ๋ฐฉ์ด ๋ฐ Playwright ์ค์ฉ ๋ฐฉ์ง | |
| defensive_instruction = ( | |
| "\n\n[โ ๏ธ ์ค์ ๊ท์ฝ: ๋๊ตฌ ์ฌ์ฉ ์ ํ]\n" | |
| "1. ์ผ๋ฐ์ ์ธ ์ฑํ ์๋ต์ด๋ ์ธ์ฌ๋ ๋ฐ๋์ ํ ์คํธ(Message)๋ก๋ง ์ํํ์ญ์์ค.\n" | |
| "2. 'sovereign_type_text'๋ 'sovereign_press_key'์ ๊ฐ์ OS ์ ์ด ๋๊ตฌ๋ ์ฌ์ฉ์๊ฐ ๋ช ์์ ์ผ๋ก " | |
| "์์คํ ์กฐ์์ด๋ ํ์ดํ์ ์์ฒญํ ๊ฒฝ์ฐ์๋ง ์ฌ์ฉํ์ญ์์ค.\n" | |
| "3. ์ ๋๋ก ์ฑํ ์๋ต์ ํ์ดํ ๋๊ตฌ๋ก ๋์ ํ์ง ๋ง์ญ์์ค.\n" | |
| "4. [์ค์] Playwright ๋ธ๋ผ์ฐ์ ๋๊ตฌ(mcp_playwright_browser_navigate)๋ฅผ ํธ์ถํ ๋, " | |
| "url ์ธ์๊ฐ์ ์ ๋๋ก ๋น์๋๊ฑฐ๋ ๋น JSON ({})์ผ๋ก ๋ณด๋ด์ง ๋ง์ญ์์ค. ๋ํ url ์ธ์์ ๊ฒ์์ด(์: '๋ํ๋ฏผ๊ตญ ๋ํต๋ น')๋ ์ผ๋ฐ ์ง๋ฌธ์ ๋ฃ์ง ๋ง์ญ์์ค. " | |
| "๋ฐ๋์ 'url': 'https://www.google.com' ๋๋ 'https://www.naver.com'๊ณผ ๊ฐ์ ์ค์ http/https URL ์ฃผ์๋ฅผ ๋ฃ์ด ๋จผ์ ํธ์ถํ์ญ์์ค.\n" | |
| "5. ๋ธ๋ผ์ฐ์ ํ์ด์ง์ ๋ค์ด๊ฐ ํ ๊ฒ์์ฐฝ ๋ฑ ์ ๋ ฅ ์์์ ํ์ดํ์ ํ ๋๋ `mcp_playwright_browser_fill_form` ์ด๋ `mcp_playwright_browser_type`์ ์ฌ์ฉํ๊ณ , " | |
| "๋ฐ๋์ selector ํน์ element ์๋ณ์๊ฐ ์ฌ๋ฐ๋ฅธ์ง ์ค๋ ์ท(`mcp_playwright_browser_snapshot`)์ ๋จผ์ ์ป์ด ํ์ธํ์ญ์์ค. Google์ ๊ฒฝ์ฐ ํ ์คํธ ์ ๋ ฅ ํ 'enter' key press๋ฅผ ์ถ๊ฐ๋ก ํธ์ถํด์ผ ๊ฒ์์ด ์คํ๋ฉ๋๋ค." | |
| ) | |
| full_system_instruction = (system_instruction or "") + defensive_instruction | |
| messages.insert(0, {"role": "system", "content": full_system_instruction}) | |
| # openclaw์ ์์ฒด ํ๋ฌ๊ทธ์ธ ๋๊ตฌ๋ฅผ ๋ณด์ โ ์๋์ฐ๋ธ๋ ์ธ ๋๊ตฌ๋ฅผ ํจ๊ป ๋ณด๋ด๋ฉด ์ด๋ฆ ์ถฉ๋ ๋ฐ์ | |
| or_tools = self._get_tools_schema(prompt) if (not skip_tools and provider != "openclaw") else None | |
| try: | |
| logger.info(f"[{display_name}] ๋ชจ๋ธ '{model_name}' ํธ์ถ ์์") | |
| max_iterations = self.dynamic_config.get("max_iterations", 15) | |
| first_yield = True | |
| def _cfg_int(name, default, min_value=1, max_value=8192): | |
| try: | |
| value = int(self.dynamic_config.get(name, default)) | |
| except (TypeError, ValueError): | |
| value = default | |
| return max(min_value, min(max_value, value)) | |
| m3_tool_max_tokens = _cfg_int("nvidia_minimax_m3_tool_max_tokens", 768, 128, 4096) | |
| m3_final_max_tokens = _cfg_int("nvidia_minimax_m3_final_max_tokens", 1024, 128, 4096) | |
| m3_chat_max_tokens = _cfg_int("nvidia_minimax_m3_max_tokens", 2048, 256, 8192) | |
| try: | |
| m3_temperature = float(self.dynamic_config.get("nvidia_minimax_m3_temperature", 0.4)) | |
| except (TypeError, ValueError): | |
| m3_temperature = 0.4 | |
| try: | |
| m3_top_p = float(self.dynamic_config.get("nvidia_minimax_m3_top_p", 0.9)) | |
| except (TypeError, ValueError): | |
| m3_top_p = 0.9 | |
| # ๐ก๏ธ [Tool Runtime] ๋๊ตฌ ๋ฃจํ ์ํ๋ฅผ ctx๋ก ์ผ์ํ (3๊ฒฝ๋ก ๊ณตํต) | |
| ctx = RuntimeCtx(prompt, interactive=interactive, | |
| debug=_is_debug_mode(), display_name=display_name) | |
| # ๐๏ธ ์ต์๋์ธ ์์ฑ ์๋๋ฉด, ์ฝ์๋ง ํ๊ณ obsidian_write๋ฅผ ์ ๋ถ๋ฅด๋ฉด ์ฌ์ดํ๋๋ก ํ์ | |
| ctx.expect_obsidian_write = self._wants_obsidian(prompt) and self._wants_obsidian_write(prompt) | |
| for _ in range(max_iterations): | |
| # [๐ฑ Relay Pinning] ๋ง์๋์ ์์ฒญ์ ๋ฐ๋ผ ํน์ ์ฑ๋(Relay)๋ก ๊ฐ์ ๊ณ ์ | |
| # session_id๊ฐ ์ ๊ณต๋ ๊ฒฝ์ฐ(DM ๋ฑ) ์ด๋ฅผ ์ฐ์ ์ ์ผ๋ก ์ฌ์ฉํฉ๋๋ค. | |
| effective_session = session_id or "mawang_jarvis_relay" | |
| # NVIDIA NIM: nemotron์ streaming, ๋๋จธ์ง(MiniMax/DeepSeek/StepFun)๋ non-streaming | |
| _nvidia_streaming_models = {"nvidia/nemotron-3-ultra-550b-a55b", "z-ai/glm-5.1"} | |
| is_nvidia_streaming = ( | |
| provider == "nvidia" | |
| and str(model_name or "").lower() in _nvidia_streaming_models | |
| ) | |
| is_nvidia_minimax_m3 = provider == "nvidia" and not is_nvidia_streaming | |
| json_data = { | |
| "model": model_name, | |
| "messages": messages, | |
| "stream": False if is_nvidia_minimax_m3 else True, | |
| } | |
| if is_nvidia_minimax_m3: | |
| # NVIDIA MiniMax-M3 is sensitive to the generic OpenRouter-style | |
| # streaming/tool loop. Match NVIDIA's reference request shape. | |
| m3_has_tools = bool(or_tools and not ctx.force_no_tools) | |
| json_data.update({ | |
| "max_tokens": ( | |
| m3_tool_max_tokens | |
| if m3_has_tools | |
| else m3_final_max_tokens | |
| if ctx.force_no_tools | |
| else m3_chat_max_tokens | |
| ), | |
| "temperature": m3_temperature, | |
| "top_p": m3_top_p, | |
| }) | |
| else: | |
| json_data["user"] = effective_session | |
| json_data["stream_options"] = {"include_usage": True} # ๐ช ํ ํฐ ์ฌ์ฉ๋ ์ง๊ณ | |
| if or_tools and not ctx.force_no_tools: | |
| json_data["tools"] = or_tools | |
| json_data["tool_choice"] = "auto" | |
| res = requests.post( | |
| f"{base_url}/chat/completions", | |
| headers={ | |
| "Authorization": f"Bearer {api_key}", | |
| "Content-Type": "application/json", | |
| "Accept": "application/json" if is_nvidia_minimax_m3 else "text/event-stream", | |
| **({} if provider == "nvidia" else { | |
| "HTTP-Referer": "https://jarvis-imperial.com", | |
| "X-Title": "Imperial Shadow Brain", | |
| }), | |
| }, | |
| json=json_data, | |
| stream=not is_nvidia_minimax_m3, | |
| timeout=300 | |
| ) | |
| if res.status_code == 429: | |
| yield f"โ ๏ธ [{display_name}] Rate Limit ์ด๊ณผ (๋ฌด๋ฃ ๋ชจ๋ธ ํ๋). ์ ์ ํ ๋ค์ ์๋ํ๊ฑฐ๋ ๋ค๋ฅธ ๋ชจ๋ธ์ ์ ํํ์ธ์." | |
| return | |
| res.raise_for_status() | |
| if is_nvidia_minimax_m3: | |
| data = res.json() | |
| self._emit_token_usage(provider, model_name, data.get("usage")) # ๐ช | |
| message = ((data.get("choices") or [{}])[0].get("message") or {}) | |
| reasoning = message.get("reasoning_content") or "" | |
| content = message.get("content") or "" | |
| # ๐ง [Reasoning ๊ฒ์ดํธ] show_reasoning์ด ๊บผ์ ธ ์์ผ๋ฉด(๊ธฐ๋ณธ) ์ถ๋ก ์ฑ๋์ | |
| # ์ต์ข ๋ต๋ณ์์ ์ ์ธํ๋ค โ minimax ๊ณ์ด์ ๋ฉํ/์ง์ ๋ฉ์๋ฆฌ ๋์ถ ์ฐจ๋จ. | |
| # content๊ฐ ๋น๋ฉด reasoning์ผ๋ก ํด๋ฐฑ(๋ต๋ณ ์ ์ค ๋ฐฉ์ง). | |
| if _show_reasoning(): | |
| full_content = f"{reasoning}{content}".strip() | |
| else: | |
| full_content = _pick_nonreasoning_final(content, reasoning).strip() | |
| tool_calls = message.get("tool_calls") or [] | |
| normalized_tool_calls = [] | |
| for i, tc in enumerate(tool_calls): | |
| fn = (tc.get("function") or {}) if isinstance(tc, dict) else {} | |
| args = fn.get("arguments", "") | |
| if isinstance(args, (dict, list)): | |
| args = json.dumps(args, ensure_ascii=False) | |
| normalized_tool_calls.append({ | |
| "id": tc.get("id") or f"call_m3_{i}", | |
| "type": tc.get("type") or "function", | |
| "function": { | |
| "name": fn.get("name") or "", | |
| "arguments": args or "", | |
| }, | |
| }) | |
| if not normalized_tool_calls: | |
| if full_content: | |
| # ๐๏ธ ์ต์๋์ธ ์์ฑ ์ฝ์๋ง ํ๊ณ ๋๊ตฌ ๋ฏธํธ์ถ์ด๋ฉด ํ ๋ฒ ๋ ๊ฐ์ ์ฌ์ด | |
| _nudge = self.runtime.nudge_obsidian_write(full_content, messages, ctx) | |
| if _nudge == "continue": | |
| continue | |
| yield full_content | |
| else: | |
| _action = yield from self.runtime.handle_no_tool_calls(full_content, messages, ctx) | |
| if _action == "continue": | |
| continue | |
| break | |
| yield from self.runtime.run_tool_calls(normalized_tool_calls, full_content, messages, ctx) | |
| m3_tool_names = [ | |
| (tc.get("function") or {}).get("name", "") | |
| for tc in normalized_tool_calls | |
| ] | |
| browser_mutated = any(name in { | |
| "mcp_playwright_browser_type", | |
| "mcp_playwright_browser_fill_form", | |
| "mcp_playwright_browser_click", | |
| "mcp_playwright_browser_press_key", | |
| } for name in m3_tool_names) | |
| asked_user = "ask_user" in m3_tool_names | |
| if browser_mutated and not getattr(ctx, "m3_final_snapshot_added", False): | |
| try: | |
| snapshot = self.runtime.dispatch( | |
| "mcp_playwright_browser_snapshot", {}, ctx | |
| ) | |
| except Exception as snap_err: | |
| snapshot = f"ERROR: automatic browser snapshot failed: {snap_err}" | |
| ctx.m3_final_snapshot_added = True | |
| ctx.force_no_tools = True | |
| # MiniMax-M3 can hang on OpenAI-style role="tool" history. | |
| # For the final answer, compact the browser evidence into a | |
| # clean user message and disable tools. | |
| messages[:] = [ | |
| {"role": "system", "content": full_system_instruction}, | |
| { | |
| "role": "user", | |
| "content": ( | |
| "์๋ ์ง๋ฌธ:\n" | |
| f"{prompt}\n\n" | |
| "์๋ ๋ธ๋ผ์ฐ์ ์ค๋ ์ท ๊ฒฐ๊ณผ์ ๋๋ค. ์ด ๋ด์ฉ์ ๊ทผ๊ฑฐ๋ก ํ๊ตญ์ด๋ก " | |
| "๊ฐ๊ฒฐํ๊ฒ ์ต์ข ๋ต๋ณํ์ญ์์ค. ๋๊ตฌ๋ฅผ ๋ค์ ํธ์ถํ์ง ๋ง์ญ์์ค.\n\n" | |
| f"{str(snapshot)[:6000]}" | |
| ), | |
| }, | |
| ] | |
| elif asked_user and not getattr(ctx, "m3_ask_final_compacted", False): | |
| ask_result = "" | |
| for msg in reversed(messages): | |
| if msg.get("role") == "tool" and msg.get("name") == "ask_user": | |
| ask_result = str(msg.get("content") or "") | |
| break | |
| ctx.m3_ask_final_compacted = True | |
| ctx.force_no_tools = True | |
| messages[:] = [ | |
| {"role": "system", "content": full_system_instruction}, | |
| { | |
| "role": "user", | |
| "content": ( | |
| "์๋ ์ง๋ฌธ:\n" | |
| f"{prompt}\n\n" | |
| "์ฌ์ฉ์๊ฐ ์ ํ์ง ์นด๋์์ ๋ค์์ฒ๋ผ ์ ํํ์ต๋๋ค. " | |
| "์ด ์ ํ์ ๋ฐ์ํ์ฌ ํ๊ตญ์ด๋ก ๊ฐ๊ฒฐํ๊ฒ ์ต์ข ๋ต๋ณํ์ญ์์ค. " | |
| "๋๊ตฌ๋ฅผ ๋ค์ ํธ์ถํ์ง ๋ง์ญ์์ค.\n\n" | |
| f"{ask_result[:2000]}" | |
| ), | |
| }, | |
| ] | |
| continue | |
| full_content = "" | |
| tool_calls = [] | |
| reasoning_buffer = "" | |
| emit_reasoning = _show_reasoning() | |
| _usage_acc = None # ๐ช ํ ํฐ ์ฌ์ฉ๋(์ต์ข ์ฒญํฌ) | |
| for line in res.iter_lines(): | |
| if not line: | |
| continue | |
| l_str = line.decode('utf-8') | |
| if not l_str.startswith("data: "): | |
| continue | |
| data_raw = l_str[6:] | |
| if data_raw.strip() == "[DONE]": | |
| break | |
| try: | |
| chunk = json.loads(data_raw) | |
| if chunk.get("usage"): | |
| _usage_acc = chunk["usage"] | |
| if not chunk.get("choices"): | |
| continue | |
| delta = chunk["choices"][0].get("delta", {}) | |
| content = delta.get("content", "") | |
| reasoning = delta.get("reasoning_content", "") | |
| if reasoning: | |
| # ๐ง [Reasoning ๊ฒ์ดํธ] show_reasoning์ด ๊บผ์ ธ ์์ผ๋ฉด(๊ธฐ๋ณธ) ์ถ๋ก | |
| # ์ฑ๋์ ์คํธ๋ฆผยท์ต์ข ๋ต๋ณ์์ ์ ์ธํ๋ค. ๋ฉํ/์ง์ ๋ฉ์๋ฆฌ ๋์ถ ์ฐจ๋จ. | |
| if emit_reasoning: | |
| full_content += reasoning | |
| if first_yield: | |
| first_yield = False | |
| if not ctx.suppress_stream: | |
| yield reasoning # ์ถ๋ก ๊ณผ์ ๋ ์ค์๊ฐ์ผ๋ก ์ถ๋ ฅ | |
| else: | |
| reasoning_buffer += reasoning # ํด๋ฐฑ์ฉ์ผ๋ก๋ง ๋ณด๊ด(๋ฏธ์ถ๋ ฅ) | |
| if content: | |
| full_content += content | |
| if first_yield: | |
| first_yield = False | |
| if not ctx.suppress_stream: | |
| yield content | |
| tc_delta = delta.get("tool_calls", []) | |
| for tc in tc_delta: | |
| index = tc.get("index", 0) | |
| while len(tool_calls) <= index: | |
| tool_calls.append({"id": "", "type": "function", "function": {"name": "", "arguments": ""}}) | |
| if "id" in tc: | |
| tool_calls[index]["id"] += tc["id"] | |
| if "function" in tc: | |
| f = tc["function"] | |
| if "name" in f: | |
| tool_calls[index]["function"]["name"] += f["name"] | |
| if "arguments" in f: | |
| tool_calls[index]["function"]["arguments"] += f["arguments"] | |
| except Exception: | |
| pass | |
| self._emit_token_usage(provider, model_name, _usage_acc) # ๐ช | |
| if not tool_calls: | |
| # ๐ง [Reasoning ํด๋ฐฑ] ์ถ๋ก ์จ๊น ๋ชจ๋์์ content๊ฐ ๋น๊ณ ์ถ๋ก ๋ง ์จ ๊ฒฝ์ฐ | |
| # ๋ต๋ณ ์ ์ค ๋ฐฉ์ง โ reasoning์ ์ต์ข ๋ต๋ณ์ผ๋ก ์น๊ฒฉํด ์คํธ๋ฆผ์ 1ํ ๋ฐฉ์ถ. | |
| if not emit_reasoning and not full_content.strip() and reasoning_buffer.strip(): | |
| full_content = _pick_nonreasoning_final(full_content, reasoning_buffer) | |
| if first_yield: | |
| first_yield = False | |
| if not ctx.suppress_stream and full_content: | |
| yield full_content | |
| # ๐ก๏ธ [Tool Runtime] ๋๊ตฌ ์๋ ์๋ต โ Empty-Final / Fake-Ask / Markdown-Ask ๊ตฌ์ ์ผ์ํ | |
| _action = yield from self.runtime.handle_no_tool_calls(full_content, messages, ctx) | |
| if _action == "continue": | |
| continue | |
| break | |
| # ๐ก๏ธ [Tool Runtime] ๋๊ตฌ ํธ์ถ ์ผ๊ด ์คํ (๊ฒ์ฆยท๋ณด์ ยทask ๊ฐ๋ยท์ํท๋ธ๋ ์ด์ปค ๊ณตํต) | |
| yield from self.runtime.run_tool_calls(tool_calls, full_content, messages, ctx) | |
| except requests.exceptions.HTTPError as e: | |
| yield f"โ [{display_name}] HTTP ์ค๋ฅ: {e}" | |
| except Exception as e: | |
| logger.error(f"[{display_name}] ์ค๋ฅ: {e}") | |
| yield f"โ [{display_name}] ์ค๋ฅ: {str(e)}" | |
| def _load_google_cli_creds(self) -> Optional[Dict[str, Any]]: | |
| """Loads OAuth credentials from the Gemini CLI storage.""" | |
| home = os.path.expanduser("~") | |
| old_creds_path = os.path.join(home, ".gemini", "oauth_creds.json") | |
| if os.path.exists(old_creds_path): | |
| try: | |
| with open(old_creds_path, 'r', encoding='utf-8') as f: | |
| return json.load(f) | |
| except Exception as e: | |
| logger.error(f"Failed to load Gemini CLI credentials: {e}") | |
| return None | |
| def _resolve_actual_model(self, provider: str, model_name: str) -> str: | |
| """[๐ฑ Imperial] ๋ง์๋์ ์ง์: DB์์ ๋์ด์จ ๊ฐ์ ์์๋ก ๋ณํํ์ง ์๊ณ ๊ทธ๋๋ก ๋ฐํํฉ๋๋ค.""" | |
| target = model_name or self.primary_model | |
| if not target or target == "์๋": | |
| return "gemini-2.5-flash-lite" | |
| # [๐ฑ OpenRouter Aliases] | |
| t_lower = target.lower() | |
| if t_lower in ["nemotron", "nano-omni", "nemotron-free"]: | |
| return "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" | |
| return target | |
| def _gemini_manual_stream_loop(self, client, model_name, contents, callables, | |
| system_instruction, tool_config=None, max_iters=8): | |
| """๐ Gemini ์๋ ํจ์ํธ์ถ + ํ ํฐ ์คํธ๋ฆฌ๋ฐ ๋ฃจํ (stream_tokens ์ตํธ์ธ ์ ์ฉ). | |
| AFC(์๋ ํจ์ํธ์ถ)๋ ๋น์คํธ๋ฆฌ๋ฐ์ด ๊ฐ์ ๋ผ ์๋ต์ด ํ ๋ฉ์ด๋ฆฌ๋ก ๋์จ๋ค. | |
| ์ฌ๊ธฐ์๋ AFC๋ฅผ ๋๊ณ ์คํธ๋ฆผ์์ ํ ์คํธ ๋ธํ๋ ์ฆ์ yield, function_call์ | |
| ์์งํด ์ง์ ์คํํ ๋ค ๋ํ์ ๋ถ์ฌ ๋ฐ๋ณตํ๋ค(์๋น์ค์ ํ์๊ธฐ ํจ๊ณผ + ๋๊ตฌ ์ฌ์ฉ). | |
| ์์ธ๋ ํธ์ถ๋ถ(think)๊ฐ ์ก์ ๊ธฐ์กด AFC ๊ฒฝ๋ก๋ก ํด๋ฐฑํ๋ค. | |
| """ | |
| tool_map = {} | |
| for fn in callables: | |
| if callable(fn) and hasattr(fn, "__name__"): | |
| tool_map[fn.__name__] = fn | |
| contents = list(contents) | |
| safety = [ | |
| types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_CIVIC_INTEGRITY", threshold="BLOCK_NONE"), | |
| ] | |
| for it in range(max_iters): | |
| cfg_args = dict( | |
| system_instruction=system_instruction, | |
| tools=callables, | |
| temperature=0.0, | |
| automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), | |
| safety_settings=safety, | |
| ) | |
| # Task Mode์ ๋๊ตฌ ๊ฐ์ ๋ ์ฒซ ํด์๋ง โ ๋งค ํด ๊ฐ์ ํ๋ฉด ์ต์ข ๋ต๋ณ์ ๋ชป ๋ธ๋ค | |
| if tool_config is not None and it == 0: | |
| cfg_args["tool_config"] = tool_config | |
| stream = client.models.generate_content_stream( | |
| model=model_name, contents=contents, | |
| config=types.GenerateContentConfig(**cfg_args), | |
| ) | |
| fcalls = [] | |
| text_parts = [] | |
| usage = None | |
| for chunk in stream: | |
| um = getattr(chunk, "usage_metadata", None) | |
| if um is not None: | |
| usage = um | |
| for cand in (getattr(chunk, "candidates", None) or [])[:1]: | |
| content = getattr(cand, "content", None) | |
| for part in (getattr(content, "parts", None) or []): | |
| fc = getattr(part, "function_call", None) | |
| if fc is not None and getattr(fc, "name", None): | |
| fcalls.append(fc) | |
| pt = getattr(part, "text", None) | |
| if pt: | |
| text_parts.append(pt) | |
| yield pt # ๐ ํ ํฐ ์ฆ์ ๋ฐฉ์ถ | |
| if usage is not None: | |
| self._emit_token_usage("google", model_name, usage) # ๐ช | |
| if not fcalls: | |
| return # ์ต์ข ๋ต๋ณ ์๋ฃ | |
| # ๋ชจ๋ธ ํด(ํ ์คํธ+ํจ์ํธ์ถ) ์ฌ๊ตฌ์ฑ ํ ๋๊ตฌ ์คํ ๊ฒฐ๊ณผ๋ฅผ ๋ถ์ฌ ๋ค์ ํด์ผ๋ก | |
| model_parts = [] | |
| if text_parts: | |
| model_parts.append(types.Part(text="".join(text_parts))) | |
| model_parts.extend(types.Part(function_call=fc) for fc in fcalls) | |
| contents.append(types.Content(role="model", parts=model_parts)) | |
| resp_parts = [] | |
| for fc in fcalls: | |
| name = getattr(fc, "name", "") or "" | |
| args = dict(getattr(fc, "args", None) or {}) | |
| logger.info(f"๐ [TokenStream] ๋๊ตฌ ์คํ: {name}({list(args.keys())})") | |
| fn = tool_map.get(name) | |
| if fn is None: | |
| result = f"์ค๋ฅ: '{name}' ๋๊ตฌ๋ฅผ ์ฐพ์ ์ ์์ต๋๋ค." | |
| else: | |
| try: | |
| result = fn(**args) | |
| except Exception as tool_err: | |
| result = f"๋๊ตฌ ์คํ ์ค๋ฅ: {tool_err}" | |
| if isinstance(result, dict): | |
| payload = result | |
| else: | |
| payload = {"result": str(result)[:20000]} | |
| resp_parts.append(types.Part.from_function_response(name=name, response=payload)) | |
| yield f"[STAGE:{name}]" # ์งํ ๋ง์ปค โ ํ์๋จ(์ ์ )์์ ์ ๊ฑฐ๋จ | |
| contents.append(types.Content(role="tool", parts=resp_parts)) | |
| yield "\nโ ๏ธ ๋๊ตฌ ๋ฐ๋ณต ์ํ(8ํ)์ ๋๋ฌํด ์ฌ๊ธฐ๊น์ง์ ๋ด์ฉ์ผ๋ก ๋ง๋ฌด๋ฆฌํฉ๋๋ค." | |
| def think(self, prompt: str, history: list = None, model_name: str = None, images: list = None, system_instruction: str = None, | |
| skip_fallback: bool = False, provider: str = None, skip_tools: bool = False, | |
| tools: list = None, session_id: str = None, token: str = None, aegis_url: str = None, | |
| override_config: dict = None, is_master: bool = False, | |
| interactive: bool = True, skip_grounding: bool = False, | |
| stream_tokens: bool = False, | |
| coding_intent: bool = None) -> Generator[str, None, None]: | |
| """ | |
| Autonomous thinking loop with Tool Use (Function Calling). | |
| Supports multi-modal input if images are provided. | |
| Accepts system_instruction for persona injection. | |
| Tries provided model, and auto-falls back to configured secondary on failure. | |
| """ | |
| # [๐ง ์ ์ง ๊ฒ์ดํธ] ๋๊ตฌ(recall_memory/count_history)๊ฐ ํธ์ถ ์์ ์ ์ธ ์์ฒญ ์ปจํ ์คํธ ์ ์ฅ | |
| self._request_ctx.token = token | |
| self._request_ctx.aegis_url = aegis_url | |
| self._request_ctx.session_id = session_id | |
| _lap_t = [time.time()] | |
| def _lap(name): # โฑ๏ธ think() ์ ์ฒ๋ฆฌ ํ๋กํ์ผ (TTFT ์ถ์ ์ฉ) | |
| now = time.time() | |
| if now - _lap_t[0] > 0.15: # 150ms ์ด์ ๊ฑธ๋ฆฐ ๋จ๊ณ๋ง ๋ก๊ทธ | |
| logger.info(f"โฑ๏ธ [think-prep] {name}: {(now - _lap_t[0])*1000:.0f}ms") | |
| _lap_t[0] = now | |
| # [๐๏ธ Guardian Job Auto-Routing] ์ง์ ์ค์ ์ด "auto"์ธ ๊ฒฝ์ฐ prompt ๋ถ์์ ํตํด ์ ์ ํ ์ง์ ์๋ ๋งคํ | |
| job_setting = self.dynamic_config.get("job") | |
| _lap("pre-job") | |
| from guardian_jobs import _all_jobs as _available_jobs | |
| if job_setting == "auto" and _available_jobs(aegis_url): | |
| classified_job = self._classify_job_by_prompt(prompt) | |
| logger.info(f"[๐ง Brain] Prompt classified auto-job: {classified_job} for prompt: {prompt[:35]}...") | |
| try: | |
| from guardian_jobs import ( | |
| apply_job_to_capabilities, | |
| get_job as _get_auto_job, | |
| resolve_effective_tools_skills as _resolve_auto_ets, | |
| ) | |
| temp_caps = { | |
| "job": "auto", | |
| "max_iterations": self.dynamic_config.get("max_iterations", 10), | |
| } | |
| temp_caps = apply_job_to_capabilities(temp_caps, classified_job, aegis_url=aegis_url) | |
| # ๐ฎ [์ฑ ์ธํธ] apply_job_to_capabilities ๋ toggle/rule ๋ง ๋ณํฉํ๋ค. | |
| # ๋๊ตฌ/์คํฌ ํ์ดํธ๋ฆฌ์คํธ๋ ์ฌ๊ธฐ์ ์ง์ ์ ์๋ฅผ ์ง์ ์ฝ์ด ์ฅ์ฐฉ ์ฑ(equipped_apps) | |
| # ๊น์ง ๋๊ตฌ/์คํฌ๋ก ํผ์ณ ๋ฐ์ํ๋ค(์ ์ ๊ฒฝ๋ก core.py:~583 ๊ณผ ๋์ผ ๋์). | |
| _auto_job = _get_auto_job(classified_job, aegis_url=aegis_url) or {} | |
| _auto_tools, _auto_skills = _resolve_auto_ets(_auto_job) | |
| self.dynamic_config["allowed_tools"] = _auto_tools | |
| self.dynamic_config["tool_mode"] = _auto_job.get("tool_mode", "all") | |
| self.dynamic_config["equipped_skills"] = _auto_skills | |
| self.dynamic_config["obsidian_job_path"] = temp_caps.get("obsidian_job_path", "") | |
| self.dynamic_config["job_source"] = temp_caps.get("job_source", "cloud") | |
| if temp_caps.get("num_ctx"): | |
| self.dynamic_config["num_ctx"] = temp_caps.get("num_ctx") | |
| if temp_caps.get("num_predict"): | |
| self.dynamic_config["num_predict"] = temp_caps.get("num_predict") | |
| # job_rule์ด ์ฃผ์ ๋์๋ค๋ฉด system_instruction์ ๋ณํฉํ๊ฑฐ๋ ๋ฎ์ด์๋๋ค. | |
| job_rule = temp_caps.get("job_rule", "") | |
| if job_rule: | |
| if system_instruction: | |
| system_instruction = f"{system_instruction}\n\n[์ง์ ํ๋ ์ง์นจ: {classified_job}]\n{job_rule}" | |
| else: | |
| system_instruction = f"[์ง์ ํ๋ ์ง์นจ: {classified_job}]\n{job_rule}" | |
| except Exception as _job_err: | |
| logger.warning(f"[๐ง Brain] ์๋ ์ง์ ๋ผ์ฐํ ์ ์ฉ ์คํจ: {_job_err}") | |
| _lap("job-routing") | |
| # [๐ก๏ธ] Dynamic Configuration Refresh: ๋ก์ปฌ ์์ด์ ํธ ๋ชจ๋์ธ ๊ฒฝ์ฐ ๋งค ์์ฒญ๋ง๋ค Aegis์์ ์ต์ ์ค์ ์ ๊ฐ์ ธ์ต๋๋ค. | |
| if os.environ.get("LOCAL_AGENT_MODE", "false").lower() == "true": | |
| try: | |
| new_config = self.get_dynamic_config(token=token, override_config=override_config, aegis_url=aegis_url) | |
| if new_config: | |
| self.dynamic_config = new_config | |
| logger.info("[๐ง Brain] Dynamic config refreshed via delegated token.") | |
| except Exception as e: | |
| logger.warning(f"[๐ง Brain] Failed to refresh dynamic config: {e}") | |
| _lap("dyn-config") | |
| # ๐๏ธ [Ollama ์ ์ ์ค์ ๊ฐ์ ๋ฐ์] override_config(persona_settings)๊ฐ ๋๊ธด | |
| # num_ctx/num_predict๋ LOCAL_AGENT_MODE ์ฌ๋ถ์ ๋ฌด๊ดํ๊ฒ ํญ์ dynamic_config์ | |
| # ๋ฐ์ํ๋ค. (์งํต์ฑํ ์ LOCAL_AGENT_MODE๊ฐ ๊บผ์ ธ ์์ด๋ ์ง์ ์ ์๋ต ํ ํฐ ์ํ์ด | |
| # _think_ollama๊น์ง ๋๋ฌํด์ผ ํ๋ฏ๋ก โ ์ ๊ทธ๋ฌ๋ฉด num_predict๊ฐ โ๋ก ๋ฌด์๋จ.) | |
| if override_config: | |
| _merge = {} | |
| for _k in ("num_ctx", "num_predict", "job_source", | |
| "obsidian_job_path", "obsidian_context"): | |
| if override_config.get(_k) is not None: | |
| _merge[_k] = override_config[_k] | |
| if _merge: | |
| self.dynamic_config = {**(self.dynamic_config or {}), **_merge} | |
| # ๐งญ [Imperial Obsidian Context Profile] | |
| # CLOUD๋ ๊ธฐ์กด Aegis ์ง์ , LOCAL(vault)์ Vault ์ง์ ยท์คํฌยท์ง์์ ์ฌ์ฉํ๋ค. | |
| try: | |
| from imperial_obsidian_context import build_context as _build_obsidian_context | |
| _obs_ctx = _build_obsidian_context(self.dynamic_config or {}, prompt) | |
| if _obs_ctx.enabled: | |
| system_instruction = (system_instruction or "") + _obs_ctx.system_context | |
| if _obs_ctx.mode == "LOCAL": | |
| self.dynamic_config["job_rule"] = _obs_ctx.job_rule | |
| self.dynamic_config["equipped_skills"] = _obs_ctx.equipped_skills | |
| self.dynamic_config["allowed_tools"] = _obs_ctx.allowed_tools | |
| self.dynamic_config["tool_mode"] = "whitelist" | |
| logger.info( | |
| "๐งญ [Obsidian Context] mode=%s policy=%s sources=%s", | |
| _obs_ctx.mode, _obs_ctx.policy_path, len(_obs_ctx.sources), | |
| ) | |
| elif _obs_ctx.warnings: | |
| logger.warning("[Obsidian Context] %s", " | ".join(_obs_ctx.warnings)) | |
| except Exception as _obs_ctx_err: | |
| logger.warning("[Obsidian Context] ์์ ํด๋ฐฑ(SYSTEM): %s", _obs_ctx_err) | |
| if provider is None: | |
| provider = self.primary_provider | |
| # [๐ฑ Auto-Provider Routing] ๋ชจ๋ธ๋ช ์ ํน์ ํค์๋๊ฐ ์์ผ๋ฉด ํ๋ก๋ฐ์ด๋ ์๋ ์ ํ | |
| m_low = (model_name or "").lower() | |
| if "minimaxai/" in m_low: | |
| provider = "nvidia" | |
| logger.info(f"[๐ง Brain] Auto-routed to NVIDIA based on model: {model_name}") | |
| elif any(k in m_low for k in ["nemotron", "nvidia", "qwen", "meta/", "mistral/", "anthropic/"]): | |
| provider = "openrouter" | |
| logger.info(f"[๐ง Brain] Auto-routed to OpenRouter based on model: {model_name}") | |
| provider = str(provider or "").lower() | |
| # [๐ฑ Model-Namespace Override] ๋ชจ๋ธ ์ ๋์ฌ๊ฐ ๊ณต๊ธ์๋ฅผ ๋ช ํํ ์ง์ํ๋๋ฐ provider๊ฐ ์ด๊ธ๋๋ฉด | |
| # ๊ฐ์ ๊ต์ ํ๋ค. (ํค์๋ฌ์ด NVIDIA NIM์ ๊ณจ๋์ด๋ provider๋ฅผ nvidia๋ก ์ ๋ณด๋ด๊ณ google๋ก ๋ณด๋ด๋ | |
| # ๋ถ์ผ์น โ minimaxai/* ๋ชจ๋ธ์ด Gemini ์๋ํฌ์ธํธ๋ก ๊ฐ 404 ๋๋ ๋ฌธ์ ์ฐจ๋จ.) | |
| # minimax/glm ๋ค์์คํ์ด์ค๋ ํด๋น ๊ณต๊ธ์ ์ ์ฉ์ด๋ผ ์์ ํ๊ฒ ๊ฐ์ ๊ฐ๋ฅ. | |
| _m_ns = (model_name or "").lower() | |
| _forced = None | |
| if "minimaxai/" in _m_ns: | |
| _forced = "nvidia" | |
| elif "zai-org/" in _m_ns or _m_ns.startswith("glm"): | |
| _forced = "glm" | |
| if _forced and provider != _forced: | |
| logger.info(f"[๐ง Brain] ๋ชจ๋ธ ๋ค์์คํ์ด์ค๋ก provider ๊ต์ : '{provider}' โ '{_forced}' (model={model_name})") | |
| provider = _forced | |
| _lap("obsidian-ctx") | |
| # ๐ ๏ธ๐ [Imperial Universal Gate] ์ฝ๋ฉ/๊ฒ์ ์ง์ ์ฃผ์ ์ ๋ชจ๋ ํ๋ก๋ฐ์ด๋์ ์ ์ฉ. | |
| # (์ด ๋ถ๊ธฐ ์๋์ OpenAI ํธํ ๊ฒฝ๋ก๋ค์ Gemini ๊ฒฝ๋ก์ ์ฃผ์ ๋ก์ง์ ๊ฑฐ์น์ง ์์ผ๋ฏ๋ก | |
| # ์ฌ๊ธฐ์ ๋จผ์ ์ฃผ์ ํ๋ค โ "๋ํต๋ น ๋๊ตฌ์ง"์ ๋ชจ๋ธ ๊ธฐ์ต์ผ๋ก ๋ตํ๋ ๋ฌธ์ ์ ๊ทผ๋ณธ ์ฐจ๋จ) | |
| # ๐ [Ask ์ค์ผ ๋ฐฉ์ง] ๊ณผ๊ฑฐ ์๋ต์ ์ ์ฅ๋ imperial-ask ๋ธ๋ก์ด ํ์คํ ๋ฆฌ/ํ๋กฌํํธ๋ก ์ฌ์ฃผ์ ๋๋ฉด | |
| # ๋ชจ๋ธ์ด ask_user ๋๊ตฌ ํธ์ถ ๋์ ๋ธ๋ก ํ ์คํธ๋ฅผ ํ๋ด ๋ธ๋ค(๊ฐ์ง ask_id โ UI ๋ฏธํ์, ๊ฐ์ง ๋๊ธฐ). | |
| # ๋ชจ๋ธ์ด ๋๊ตฌ ํธ์ถ ๋์ ์ธ์ JSON({"question":...,"options":...})์ ๋ณธ๋ฌธ์ ์ด ์คํจ ์ฌ๋ก๋ | |
| # ํ์คํ ๋ฆฌ์ ๋จ์ผ๋ฉด ๊ฐ์ ํ๋ด๋ฅผ ๋ฐ๋ณตํ๋ฏ๋ก ํจ๊ป ์ ๊ฑฐํ๋ค. | |
| _ask_block_re = re.compile( | |
| r"```imperial-ask[\s\S]*?```" | |
| r"|\{[^{}]*\"ask_id\"[^{}]*\}" | |
| r"|\{[^{}]*\"question\"[^{}]*\"options\"[^{}]*\}" | |
| ) | |
| _ask_taint = ('imperial-ask', '"ask_id"', '"question"') | |
| if prompt and any(t in prompt for t in _ask_taint): | |
| prompt = _ask_block_re.sub("[์ ํ์ง ์ง๋ฌธ โ ์๋ฃ๋จ]", prompt) | |
| if history and isinstance(history, list): | |
| for _hm in history: | |
| _hc = _hm.get("content") if isinstance(_hm, dict) else None | |
| if _hc and any(t in str(_hc) for t in _ask_taint): | |
| _hm["content"] = _ask_block_re.sub("[์ ํ์ง ์ง๋ฌธ โ ์๋ฃ๋จ]", str(_hc)) | |
| # [๐ฑ Performance Bypass] If use_history is disabled, bypass history context completely | |
| _use_hist_val = (self.dynamic_config or {}).get("use_history") | |
| if _use_hist_val is False: | |
| logger.info("[๐ง Brain] use_history is False โ bypassing all conversation history context.") | |
| history = [] | |
| # inject_guidelines๊ฐ ๊บผ์ ธ ์์ผ๋ฉด ๋์ ์์คํ ์ง์นจ ์ฃผ์ ์ ์๋ตํ๋ค. | |
| # to maximize Ollama Prompt Cache hit rate and avoid GPU/CPU overhead. | |
| # Fallback to checking memory_recall if inject_guidelines is not explicitly set. | |
| _inj_val = (self.dynamic_config or {}).get("inject_guidelines") | |
| if _inj_val is not None: | |
| is_no_job = bool(_inj_val) is False | |
| else: | |
| is_no_job = (self.dynamic_config or {}).get("memory_recall", True) is False | |
| # ๐ง [์ ์ง ๊ฒ์ดํธ โ ๊ธฐ์ต ์ ์ฅ ํ๊ฐ ์ฐจ๋จ] ๋ชจ๋ธ์๊ฒ๋ ์ ์ฅ ๋๊ตฌ๊ฐ ์๋ค(์ ์ฅ์ ๋ฐฑ๊ทธ๋ผ์ด๋ ์๋). | |
| # "๋ฑ๋ก/๋ณด๊ด/์ ์ฅํ๋ค" ๋จ์ ๋ฐํ๋ฅผ ์์ ๊ธ์งํ๊ณ , ์ ์ฅ์ ์ฅ์ ์ ์ฅ์ ์ฌ์ค์ ๊ณ ์งํ๋ค. | |
| if not is_no_job and '๊ธฐ์ต ์ ์ง ์์น' not in (system_instruction or ''): | |
| memory_honesty = ( | |
| "\n\n## ๐ง ๊ธฐ์ต ์ ์ง ์์น\n" | |
| "๋์๊ฒ๋ ๊ธฐ์ต์ ์ง์ ์ ์ฅํ๋ ๋๊ตฌ๊ฐ ์๋ค. ๋ํ ๊ธฐ์ต ์ ์ฅ์ ๋ฐฑ๊ทธ๋ผ์ด๋์์ " | |
| "์๋์ผ๋ก ์๋๋๋ฉฐ ์คํจํ ์๋ ์๋ค. ๋ฐ๋ผ์ ์ ๋ '์ ์ฅํ๋ค/๋ฑ๋กํ๋ค/๋ณด๊ดํ๋ค/" | |
| "์๊ตฌํ ๊ธฐ๋กํ๋ค'๊ณ ๋จ์ ํ์ง ๋ง๋ผ. ์ฌ์ฉ์๊ฐ ๊ธฐ์ตํด ๋ฌ๋ผ๊ณ ํ๋ฉด " | |
| "'๊ธฐ์ตํด ๋๋๋ก ํ๊ฒ ์ต๋๋ค' ์์ค์ผ๋ก๋ง ๋ตํ๊ณ , ์ค์ ๊ธฐ์ต ์ฌ๋ถ๋ " | |
| "recall_memory ๊ฒ์ ๊ฒฐ๊ณผ๋ก๋ง ํ๋จํ๋ผ." | |
| ) | |
| health_notice = sovereign_memory.memory_health_notice() | |
| if health_notice: | |
| memory_honesty += ( | |
| f"\nโ ๏ธ {health_notice} " | |
| "์ด ์ํ์์๋ ๊ธฐ์ต ์ฝ์ ์์ฒด๋ฅผ ํ์ง ๋ง๊ณ , ๊ธฐ์ต ๊ธฐ๋ฅ ์ฅ์ ๋ฅผ ์์งํ๊ฒ ์๋ ค๋ผ." | |
| ) | |
| system_instruction = (system_instruction or "") + memory_honesty | |
| # ๐บ๏ธ [์ ์ง ๊ฒ์ดํธ โ ์ ๊ทผ ๋ฒ์ ๊ณ ์ง] ํ์ผ ๊ฒ์ ๋ฒ์๊ฐ ํ์ดํธ๋ฆฌ์คํธ๋ก ์ ํ๋จ์ ๋ชจ๋ธ์ ์๋ ค | |
| # "๋ฒ์ ๋ฐ์ด๋ผ ํ์ธ ๋ถ๊ฐ"์ "์กด์ฌํ์ง ์์"์ ๊ตฌ๋ถํด ๋ตํ๊ฒ ํ๋ค. (์๋ชป๋ ํด๋ ๊ธฐ์ค ์ถ์ธก ์ฐจ๋จ) | |
| if not is_no_job and '์ ๊ทผ ๋ฒ์ ์์น' not in (system_instruction or ''): | |
| scope_note = ( | |
| "\n\n## ๐บ๏ธ ์ ๊ทผ ๋ฒ์ ์์น\n" | |
| "๋์ ํ์ผ ๊ฒ์ ๋๊ตฌ๋ ํ์ฉ๋ ํด๋(ํ์ดํธ๋ฆฌ์คํธ) ์๋ง ๋ณผ ์ ์๋ค." | |
| ) | |
| allowed_dirs = self._get_mcp_allowed_dirs() | |
| if allowed_dirs: | |
| scope_note += f" ํ์ฌ ํ์ฉ ๋ฒ์: {', '.join(allowed_dirs)}." | |
| scope_note += ( | |
| "\n๊ท์น: ์์คํ ๊ตฌ์ฑ/ํ์ผ ์์น ์ง๋ฌธ์๋ ํ์ผ ๊ฒ์ ์ ์ ๋ฐ๋์ search_knowledge๋ก " | |
| "์ง์ ๋ฒ ์ด์ค๋ฅผ ๋จผ์ ์กฐํํ๋ผ. ํ์ฉ ๋ฒ์ ์์์ ๋ชป ์ฐพ์๋ค๋ฉด '์กด์ฌํ์ง ์๋๋ค'๊ฐ ์๋๋ผ " | |
| "'๋ด ์ ๊ทผ ๋ฒ์ ๋ฐ์ผ ์ ์์ด ํ์ธ ๋ถ๊ฐ'๋ผ๊ณ ๋ตํ๋ผ. ๋ฒ์ ์์์ ๋ณด์ด๋ ํ์ผ์ด " | |
| "๊ตฌ๋ฒ์ /๋ฐฑ์ ์ผ ๊ฐ๋ฅ์ฑ๋ ํญ์ ๊ณ ๋ คํ๊ณ , ๋ถ๋ถ ๊ฒ์ ๊ฒฐ๊ณผ๋ก ์ ์ฒด ์์คํ ๊ตฌ์กฐ๋ฅผ ๋จ์ ํ์ง ๋ง๋ผ. " | |
| "์๋ก์ด ์ฌ์ค์ ํ์ธํ๋ฉด save_knowledge๋ก ์ฆ์ ๊ธฐ๋กํ๋ผ " | |
| "(์ด PC์์๋ง ์ฐธ์ธ ์ฌ์ค์ scope='environment', ์ด๋์๋ ์ฐธ์ธ ์ฌ์ค์ scope='global')." | |
| "\n์ ๋ขฐ ๋ฑ๊ธ ์์น: ๋ต๋ณ ์ ๊ทผ๊ฑฐ ๋ฑ๊ธ์ ๊ตฌ๋ถํ๋ผ โ " | |
| "โ ๋ฐฉ๊ธ check_port/find_process ๋ฑ์ผ๋ก ์ด ํ๊ฒฝ์์ ์ง์ ๊ด์ธกํ ์ฌ์ค์ ๋จ์ ๊ฐ๋ฅ, " | |
| "๐์ง์ ๋ฒ ์ด์ค ๊ธฐ๋ก์ '๊ธฐ๋ก์ ~์ด๋ค(ํ์ธ์ผ)'๋ก ์ ๋ฌ, ๐ค์ถ๋ก ์ ๋ฐ๋์ ์ถ์ ์์ ๋ฐํ๋ผ. " | |
| "ํ๊ฒฝ ์์กด์ ์ฌ์ค(ํฌํธ, ์คํ ์ํ, ๊ฒฝ๋ก)์ ๊ฐ๋ฅํ๋ฉด ๊ด์ธก ๋๊ตฌ๋ก ์ฌ๊ฒ์ฆํ ๋ค ๋ตํ๋ผ." | |
| ) | |
| system_instruction = (system_instruction or "") + scope_note | |
| # ๐ [์ ํ์ง ์ง๋ฌธ ์์น] ๋ํํ ๊ฒฝ๋ก์์๋ ์ ์ ์๊ฒ ๊ณ ๋ฅด๊ฒ ํ ์ง๋ฌธ์ด ์๊ธฐ๋ฉด | |
| # ํ ์คํธ ๋ชฉ๋ก ๋์ด ๋์ ask_user ๋๊ตฌ๋ฅผ ๋ฅ๋์ ์ผ๋ก ํธ์ถํ๊ฒ ํ๋ค. | |
| # (๋น๋ํํ ๋ฆด๋ ์ด(interactive=False)๋ ์ ํ์ง UI๊ฐ ๋ฐ ์ ์์ผ๋ฏ๋ก ์ฃผ์ ํ์ง ์์) | |
| if not is_no_job and interactive and '์ ํ์ง ์ง๋ฌธ ์์น' not in (system_instruction or ''): | |
| ask_note = ( | |
| "\n\n## ๐ ์ ํ์ง ์ง๋ฌธ ์์น\n" | |
| "๋ต๋ณ ์ค ์ ์ ์๊ฒ ๋ ์ด์์ ์ ํ์ง ๊ฐ์ด๋ฐ ํ๋๋ฅผ ๊ณ ๋ฅด๊ฒ ํ๋ ค๋ ๊ฒฝ์ฐ" | |
| "(A/B/C, โ โกโข ๋ฒํธ ๋ชฉ๋ก, '์ด๋ ์ชฝ์ผ๋ก ํ ๊น์?' ๋ฑ), ์ ํ์ง๋ฅผ ํ ์คํธ๋ก " | |
| "๋์ดํ๊ณ ํด์ ๋๋ด์ง ๋ง๋ผ. ๋ฐ๋์ ask_user ๋๊ตฌ๋ฅผ ํธ์ถํด ๋ฒํผ UI๋ก ์ง๋ฌธํ๋ผ. " | |
| "์ ์ ๊ฐ ask_user๋ฅผ ์ฐ๋ผ๊ณ ๋ช ์ํ์ง ์์๋, ์ ํ์ด ํ์ํ ์ํฉ์ด๋ฉด ์ค์ค๋ก " | |
| "ask_user๋ฅผ ์ฌ์ฉํ๋ ๊ฒ์ด ๊ธฐ๋ณธ ๋์์ด๋ค. " | |
| "์์ธ: ์ ํ์ง๊ฐ 7๊ฐ ์ด์์ด๊ฑฐ๋, ์์ ์์ ํ ๋ต๋ณ์ด ํ์ํ๊ฑฐ๋, ๋จ์ ์์ฌ์ " | |
| "์ง๋ฌธ์ผ ๋๋ ํ ์คํธ๋ก ๋ฌผ์ด๋ผ. ๋๊ตฌ ํธ์ถ ์ง์ /์งํ์ ๊ฐ์ ์ ํ์ง๋ฅผ ํ ์คํธ๋ก " | |
| "์ค๋ณต ๋์ดํ์ง ๋ง๋ผ. " | |
| "๊ฒฝ๊ณ : ask_user๋ ๋ฐ๋์ ํจ์ ํธ์ถ(tool call)๋ก๋ง ์คํํด์ผ ํ๋ค. " | |
| "๋ณธ๋ฌธ์ {\"question\": ..., \"options\": ...} ํํ์ JSON์ด๋ imperial-ask " | |
| "๋ธ๋ก์ ์ง์ ์ถ๋ ฅํ๋ ๊ฒ์ ๋๊ตฌ ํธ์ถ์ด ์๋๋ฉฐ, ์๋ฌด UI๋ ๋จ์ง ์๊ณ ์ ์ ์ " | |
| "๋ต๋ ์์ํ ์ค์ง ์๋๋ค. JSON์ ์ฐ๊ณ ์๋ค๋ฉด ์ฆ์ ๋ฉ์ถ๊ณ ๋๊ตฌ๋ฅผ ํธ์ถํ๋ผ." | |
| ) | |
| system_instruction = (system_instruction or "") + ask_note | |
| # ๐บ๏ธ [Place Card] ์ฅ์/๋ง์ง ์ถ์ฒ ์ ์ฃผ์ ํฌํจ ์์น ๋์ ์ฃผ์ | |
| _place_keywords = ('๋ง์ง', '์๋น', '์นดํ', '์์์ ', '์ถ์ฒ', '์ฌํ', '๊ด๊ด', 'ํธํ ', '์์', | |
| 'restaurant', 'cafe', 'travel', 'hotel', 'recommend', '๋ช ์', '๋ณผ๊ฑฐ๋ฆฌ', | |
| '๋๊ฑฐ๋ฆฌ', '๊ฐ๋ณผ๋งํ', '์ด๋', '์ด๋', 'ํฌ์ด', '์ฝ์ค', '์คํ') | |
| if not is_no_job and any(kw in prompt for kw in _place_keywords) and '์ฅ์ ์ฃผ์ ์์น' not in (system_instruction or ''): | |
| place_note = ( | |
| "\n\n## ๐บ๏ธ ์ฅ์ ์ฃผ์ ์์น (ํ์ โ ์๋ฐ ์ ์ง๋ ์นด๋ ๋ฏธํ์)\n" | |
| "์ฅ์, ๋ง์ง, ์นดํ, ๋ช ์, ํธํ , ๊ด๊ด์ง๋ฅผ ์ถ์ฒํ๊ฑฐ๋ ์ธ๊ธํ ๋๋ " | |
| "๋ฐ๋์ **์ ํํ ํ๊ตญ ๋๋ก๋ช ์ฃผ์**๋ฅผ ์ฅ์๋ช ๋ฐ๋ก ๋ค ๊ดํธ ์์ ๋ช ์ํ๋ผ.\n" | |
| "ํ์ ํ์ ์์:\n" | |
| "- ์๋ฒ๋๋ (๊ฒฝ๊ธฐ๋ ์ฉ์ธ์ ์ฒ์ธ๊ตฌ ํฌ๊ณก์ ์๋ฒ๋๋๋ก 199)\n" | |
| "- ํ๊ตญ๋ฏผ์์ด (๊ฒฝ๊ธฐ๋ ์ฉ์ธ์ ๊ธฐํฅ๊ตฌ ๋ฏผ์์ด๋ก 90)\n" | |
| "- ํ๋ด์ฒ (๊ฒฝ๊ธฐ๋ ๊ด์ฃผ์ ๋์ฒ๋ฉด ๋์ฒ์๋ก 278)\n" | |
| "์ฃผ์ ํ์: '์/๋ + ์/๊ตฐ/๊ตฌ + ์/๋ฉด/๋ + ๋ก/๊ธธ + ๋ฒ์ง' ์์๋ก ์์ฑ.\n" | |
| "์ฃผ์๊ฐ ์์ผ๋ฉด UI ์ง๋ยท์ฌ์งยทํ๊ธฐ ์นด๋๊ฐ ์ ๋ ํ์๋์ง ์๋๋ค.\n" | |
| "์ฃผ์๋ฅผ URL๋ก ๋ณํํ๊ฑฐ๋ ๋งํฌ๋ก ๊ฐ์ธ์ง ๋ง๊ณ ์์ ํ ์คํธ๋ก๋ง ์์ฑํ๋ผ.\n" | |
| "๋ฒ์ง ์ซ์๋ฅผ ๋ฐ๋์ ํฌํจํ๋ผ (์: '199', '90', '278').\n" | |
| "์ถ์ฒ ์ฅ์๊ฐ ์ฌ๋ฌ ๊ฐ๋ผ๋ฉด ๊ฐ ์ฅ์๋ง๋ค ๊ฐ๋ณ ์ฃผ์๋ฅผ ๋ช ์ํ๋ผ." | |
| ) | |
| system_instruction = (system_instruction or "") + place_note | |
| if not is_no_job and self._wants_obsidian(prompt) and 'Imperial Obsidian Directive' not in (system_instruction or ''): | |
| system_instruction = (system_instruction or "") + _OBSIDIAN_DIRECTIVE | |
| logger.info("๐๏ธ [IMPERIAL OBSIDIAN MODE] ์ต์๋์ธ ์๋ ๊ฐ์ง โ ๋ ธํธ ์์ฑ/๊ฒ์ ์ง์ ์ฃผ์ .") | |
| elif (not is_no_job | |
| # ๐ ๏ธ [Coding Intent Gate] ํธ์ถ๋ถ๊ฐ ์ฝ๋ฉ ์ฌ๋ถ๋ฅผ ๋ช ์(coding_intent)ํ๋ฉด ํค์๋ | |
| # ์ถ์ธก(_wants_coding)์ ์ฐ์ง ์๋๋ค โ cwd ์๋ ์ก๋ด("ํ ์คํธ์ผ" ๋ฑ)์ด ์ฝ๋ฉ | |
| # ๋ชจ๋๋ก ๋นจ๋ ค๊ฐ code_* ์ฌ์ด ๋ฃจํ์ 4~8์ด๋ฅผ ํ์ฐ๋ TTFT ๋ญ๋น ์ฐจ๋จ(2026-07-10 ์ค์ธก). | |
| and (self._wants_coding(prompt) if coding_intent is None else coding_intent) | |
| and 'Imperial Coding Protocol' not in (system_instruction or '')): | |
| system_instruction = (system_instruction or "") + CODING_AGENT_GUIDE | |
| logger.info("๐ ๏ธ [IMPERIAL CODING MODE] ์ฝ๋ฉ ์๋ ๊ฐ์ง โ ์์ด์ ํฑ ์ฝ๋ฉ ์์น ์ฃผ์ (์ ํ๋ก๋ฐ์ด๋).") | |
| elif not is_no_job and self._wants_media(prompt): | |
| # Deterministic media routing: | |
| # if the user explicitly asks for images/photos/GIF/video/3D, do not | |
| # ask them to choose a search engine. The media noun already decides it. | |
| system_instruction = (system_instruction or "") + ( | |
| "\n\n## Imperial Media Direct Routing\n" | |
| "The user explicitly asked for media. Do NOT ask whether to use web search.\n" | |
| "Routing rules (apply in order):\n" | |
| "1) If the user NAMED a specific source, go there directly (do NOT call ask_user):\n" | |
| " - ์ ํ๋ธ/youtube -> call search_youtube(query, limit).\n" | |
| " - Pexels -> call search_media(query, media_type='video', provider='pexels').\n" | |
| " - Pixabay -> call search_media(query, media_type='video', provider='pixabay').\n" | |
| "2) If the request is a VIDEO/๋์์/์์ search but NO source was named, you MUST FIRST call " | |
| "ask_user(question='์ด๋์ ์์์ ์ฐพ์๊น์?', options=['Pexels','Pixabay','์ ํ๋ธ']) and let the " | |
| "routing handle the choice. Do NOT pick a source yourself in this case.\n" | |
| "3) Otherwise choose directly from the request (no ask_user):\n" | |
| " - 3D, 3D model, GLB, asset -> call search_3d_model(query, limit).\n" | |
| " - image, photo, picture, wallpaper -> call search_media(query, media_type='photo').\n" | |
| " - GIF, gif, ์์งค, ์งค -> call search_media(query, media_type='gif').\n" | |
| "If the user gives a count such as 2๊ฐ/2์ฅ/two, pass it as limit when possible. " | |
| "After the tool result/card is shown, answer briefly that the requested media was found." | |
| ) | |
| logger.info("[IMPERIAL MEDIA DIRECT] Media request detected -> direct media tool routing.") | |
| elif not is_no_job and self._wants_realtime_search(prompt): | |
| # [๐ ๊ฒ์ ์์ง ์ ํ ๋ฉ๋ด] ๊ฒ์ ์์ฒญ์ด๋ฉด ์๋์ผ๋ก ์์ง์ ๊ณ ๋ฅด์ง ๋ง๊ณ , ask_user๋ก ๋จผ์ ๋ฌผ์ด๋ณธ๋ค. | |
| # - ๋ค์ด๋ฒ ์น๊ฒ์ โ web_search (๋ค์ด๋ฒ headless Playwright: ์์ฌยท์ธ๋ฌผยท๋ด์คยท๋ ์จยท์์ธ ๋ฑ) | |
| # - ์ด์ง์ค ๋ฏธ๋์ด๊ฒ์ โ search_media / search_3d_model (PexelsยทPixabay ์ด๋ฏธ์ง/GIF/๋์์/3D) | |
| system_instruction = (system_instruction or "") + ( | |
| "\n\n## ๐ ๊ฒ์ ์์ง ์ ํ ์์น (๋ฐ๋์ ์ค์)\n" | |
| "์ด ์์ฒญ์ ๊ฒ์์ด๋ค. ์ ๋ ๋ฐ๋ก ๋ตํ๊ฑฐ๋ ์์ ๋๊ตฌ๋ก ๊ฒ์ํ์ง ๋ง๊ณ , " | |
| "**๊ฐ์ฅ ๋จผ์ ask_user ๋๊ตฌ๋ฅผ ํธ์ถ**ํด ์ฌ์ฉ์์๊ฒ ๊ฒ์ ๋ฐฉ์์ ๋ฌผ์ด๋ผ.\n" | |
| "ask_user ์ธ์: question='์ด๋์ ๊ฒ์ํ ๊น์?', " | |
| "options=['์ ํ๋ธ', '๋ค์ด๋ฒ', '๋ค์', '๊ตฌ๊ธ', '์ด์ง์ค']\n" | |
| "์ฌ์ฉ์ ์ ํ ํ ๋ผ์ฐํ :\n" | |
| "- '์ ํ๋ธ' ์ ํ โ youtube_search(query) ํธ์ถ.\n" | |
| "- '๋ค์ด๋ฒ' ์ ํ โ web_search(query, engine='naver') ํธ์ถ.\n" | |
| "- '๋ค์' ์ ํ โ web_search(query, engine='daum') ํธ์ถ.\n" | |
| "- '๊ตฌ๊ธ' ์ ํ โ web_search(query, engine='google') ํธ์ถ.\n" | |
| "- '์ด์ง์ค' ์ ํ โ ์๋ฌธ์์ ์ข ๋ฅ ์ถ๋ก : 3D/GLBโsearch_3d_model, ์ด๋ฏธ์ง/์ฌ์งโsearch_media(media_type='photo'), " | |
| "์์โsearch_media(media_type='video'), GIF/์์งคโsearch_media(media_type='gif'). ์ฌ๋ฌ ์ข ๋ฅ๋ฉด ๊ฐ๊ฐ ํธ์ถ.\n" | |
| "๋จ, ์๋ฌธ์ '์ ํ๋ธ'/'youtube'๊ฐ ๋ช ์๋ผ ์์ผ๋ฉด ask_user ์์ด ๊ณง๋ฐ๋ก youtube_search(query)๋ฅผ ํธ์ถํ๋ผ.\n" | |
| "๋๊ตฌ ๊ฒฐ๊ณผ(์นด๋/URL)๋ฅผ ๊ทธ๋๋ก ์ ์ํ๊ณ ์ฌ์ดํธ ์ถ์ฒ์ผ๋ก ๋์ฒดํ์ง ๋ง๋ผ." | |
| ) | |
| logger.info("๐ [IMPERIAL SEARCH MENU] ๊ฒ์ ์์ฒญ ๊ฐ์ง โ ask_user ์์ง ์ ํ ๋ฉ๋ด ์์น ์ฃผ์ .") | |
| _lap("inject-rules") | |
| if provider in ["github", "copilot"]: | |
| yield from self._think_copilot(prompt, model_name, system_instruction=system_instruction, skip_tools=skip_tools, tools=tools) | |
| return | |
| if provider == "atlas": | |
| yield from self._think_atlas(prompt, model_name, system_instruction=system_instruction, skip_tools=skip_tools, tools=tools) | |
| return | |
| if provider == "ollama": | |
| yield from self._think_ollama( | |
| prompt, | |
| model_name, | |
| history=history, | |
| system_instruction=system_instruction, | |
| images=images, | |
| token=token, | |
| aegis_url=aegis_url, | |
| skip_tools=skip_tools, | |
| tools=tools, | |
| skip_grounding=skip_grounding, | |
| stream_tokens=stream_tokens, | |
| ) | |
| return | |
| if provider in ["openrouter", "openclaw", "relay", "nous", "hermes_proxy", "nvidia", "glm"]: | |
| yield from self._think_openrouter(prompt, model_name, system_instruction=system_instruction, skip_tools=skip_tools, tools=tools, session_id=session_id, provider=provider, interactive=interactive, images=images) | |
| return | |
| # [๐ฑ V7.0 Stateless Client Resolution] | |
| client = self._get_client(aegis_url=aegis_url, token=token) | |
| if not client: | |
| if skip_fallback: | |
| yield "โ Critical Brain Error: Neural core offline (API Key ์ํ ์คํจ)." | |
| return | |
| yield from self._think_fallback(prompt, system_instruction=system_instruction, skip_tools=skip_tools, tools=tools) | |
| return | |
| try: | |
| actual_model_name = self._resolve_actual_model(provider, model_name) | |
| if any(x in actual_model_name.lower() for x in ["risk", "audit", "security", "auditor"]): | |
| logger.info(f"Imperial Security Protocol Engaged for: {actual_model_name}") | |
| prompt = ( | |
| "[MISSION: IMPERIAL SECURITY AUDIT / RISK ASSESSMENT]\n" | |
| "๋น์ ์ ์ ๊ตญ์ ๋ณด์ ์ํธ์๋ก์ ํด๋น ์์ฒญ์ ์ ์ฌ์ ์ํ์ ๋ถ์ํ๊ณ ๋ณด์ ์ทจ์ฝ์ ์ ์๋ณํด์ผ ํฉ๋๋ค.\n\n" | |
| ) + prompt | |
| if "auditor" in actual_model_name.lower(): | |
| actual_model_name = "gemini-1.5-pro" | |
| logger.info(f"Targeting Brain Model: {actual_model_name}") | |
| # ๐ ๊ฒ์ ๋ฐ๋ ์กฐ๊ฑด์ Aegis common_retrieval_policy๋ง ์ฌ์ฉํ๋ค. | |
| # ๐ ๏ธ [Imperial Coding Gate] ์ฝ๋ฉ ์๋๊ฐ ๊ฐ์ง๋๋ฉด ๊ฒ์ ๋ชจ๋๋ณด๋ค ์ฐ์ ํ๋ค. | |
| # ("๋ฒ๊ทธ ์ฐพ์์ค" ๊ฐ์ ์์ฒญ์ด '์ฐพ์' ํค์๋๋ก ๊ฒ์ ๊ทธ๋ผ์ด๋ฉ์ ๋นจ๋ ค๊ฐ๋ ๋ฌธ์ ์ฐจ๋จ) | |
| # ๐๏ธ ์ต์๋์ธ ์๋๋ ์ฝ๋ฉ ๋ชจ๋๋ณด๋ค ์ฐ์ (ํ์ผ/ํฐ๋ฏธ๋ ๋๊ตฌ ๋ฒ๋ ์ฐจ๋จ) | |
| is_obsidian = self._wants_obsidian(prompt) | |
| is_coding = self._wants_coding(prompt) and not is_obsidian | |
| # ๐ผ๏ธ ๋ฏธ๋์ด ๊ฒ์ ์๋๋ ๊ฒ์ ๊ทธ๋ผ์ด๋ฉ๋ณด๋ค ์ฐ์ โ search_media ๋๊ตฌ๋ก ์ฒ๋ฆฌํด์ผ ํจ | |
| is_media = self._wants_media(prompt) | |
| is_pure_search = ( | |
| not images | |
| and not is_coding | |
| and not is_media | |
| and self._should_use_google_grounding(prompt, aegis_url=aegis_url) | |
| ) | |
| if is_obsidian and 'Imperial Obsidian Directive' not in (system_instruction or ''): | |
| system_instruction = (system_instruction or "") + _OBSIDIAN_DIRECTIVE | |
| logger.info("๐๏ธ [IMPERIAL OBSIDIAN MODE] ์ต์๋์ธ ์๋ ๊ฐ์ง โ ๋ ธํธ ์์ฑ/๊ฒ์ ์ง์ ์ฃผ์ .") | |
| elif is_coding and 'Imperial Coding Protocol' not in (system_instruction or ''): | |
| system_instruction = (system_instruction or "") + CODING_AGENT_GUIDE | |
| logger.info("๐ ๏ธ [IMPERIAL CODING MODE] ์ฝ๋ฉ ์๋ ๊ฐ์ง โ ์์ด์ ํฑ ์ฝ๋ฉ ์์น ์ฃผ์ .") | |
| if is_pure_search: | |
| logger.info("๐ [IMPERIAL TURBO SEARCH] ๊ฒ์ ์ง๋ฌธ ๊ฐ์ง โ ๊ตฌ๊ธ ๊ฒ์ ๊ทธ๋ผ์ด๋ฉ ๋ชจ๋ ์ง์ .") | |
| READABILITY_GUIDE = ( | |
| "\n\n## ๐ฑ [์ ๊ตญ ๊ฐ๋ ์ฑ ํ์ค / Imperial Readability Standard]\n" | |
| "1. ํต์ฌ ์ฃผ์ ๋ ๋จ๋ฝ์ ๋๋ ๋ ๊ด๋ จ์ฑ ๋์ **์ด๋ชจ์ง(Emoji)**๋ฅผ ์ ๋ชฉ ์์ ๋ฐ๋์ ํ์ฉํ์ฌ ์๊ฐ์ ์ง๊ด์ฑ์ ํฅ์์ํค์ญ์์ค.\n" | |
| "2. ์ ๋ณด ์์ฝ ์, ๋จ์ ์ค๊ธ๋ณด๋ค๋ ๊ตต์ ๊ธ์จ(**๊ฐ์กฐ**)๋ฅผ ์ฌ์ฉํด ํต์ฌ ํค์๋๋ฅผ ํ๋์ ํ์ ํ ์ ์๋๋ก ๊ตฌ์กฐํํ์ญ์์ค.\n" | |
| "3. ํญ๋ชฉ ๊ตฌ๋ถ ์ ๋ช ํํ ๋ชฉ๋ก ๊ธฐํธ(`*` ๋๋ `-`)๋ฅผ ํ์ฉํ๊ณ , ๋จ๋ฝ ์ฌ์ด์ ์ ์ ํ ๋น ์ค(์ค๋ฐ๊ฟ)์ ๋ฃ์ด ๊ฐ๋ ์ฑ์ ๊ทน๋ํํ์ญ์์ค.\n" | |
| "4. ์ฃผ์ ๊ฒฝ์ ์งํ๋ ์์น๋ ๋ณ๋์ ์น์ ์ผ๋ก ์ ๋ฆฌํ๊ณ ๋ณด๊ธฐ ์ข๊ฒ ์ ๋ํ์ญ์์ค.\n" | |
| ) | |
| system_instruction = (system_instruction or "") + READABILITY_GUIDE | |
| else: | |
| logger.info("๐ฑ [IMPERIAL MCP MODE] ๋น๊ฒ์ ์ง๋ฌธ ๊ฐ์ง โ MCP ์ปค์คํ ๋๊ตฌ ์ธํธ ์ฃผ์ .") | |
| genai_tools = [] | |
| # [๐ฑ Imperial Fix] ๋งค ์์ฒญ๋ง๋ค ์ต์ MCP ๋๊ตฌ ๋ชฉ๋ก์ ๋ค์ ๋๊ธฐํํ์ฌ ์ง์ฐ ๋ก๋ฉ๋ ๋๊ตฌ๋ ํฌํจ๋๊ฒ ํฉ๋๋ค. | |
| if get_mcp_client: | |
| try: | |
| mcp_client = get_mcp_client() | |
| if mcp_client: | |
| mcp_callables = mcp_client.get_gemini_callables() | |
| if mcp_callables: | |
| existing_names = {t.__name__ for t in self._tools if hasattr(t, '__name__')} | |
| for t in mcp_callables: | |
| if t.__name__ not in existing_names: | |
| self._tools.append(t) | |
| logger.info(f"[๐ฑ Imperial Core] ๋์ MCP ๋๊ตฌ ์ฃผ์ ๋จ: {t.__name__}") | |
| except Exception as e: | |
| logger.warning(f"[MCP] ๋์ ๋๊ตฌ ์ฃผ์ ์คํจ: {e}") | |
| actual_tools = tools or self._tools | |
| if not skip_tools and actual_tools: | |
| genai_tools = list(actual_tools) | |
| else: | |
| logger.info("Skipping AFC tools for this thinking session.") | |
| genai_tools = [] | |
| # ๐ Prepare Unified Tools (MCP + Custom ํตํฉ) | |
| # [๐ฑ Imperial Fix v2.7] Hugin(๋ธ๋ผ์ฐ์ ์กฐ์) ๋๊ตฌ๋ ๊ธฐ๋ณธ์ ์ผ๋ก ๋๊ตฌ ์ธํธ์์ ์ ์ธํ๋ค. | |
| # ๋ง์๋๊ป์ ๋ช ์์ ์ผ๋ก ๋ธ๋ผ์ฐ์ ์ ์ด/์คํฌ๋ฆฐ์ท์ ์์ฒญํ์ค ๋๋ง(_wants_hugin) ํฌํจํ๋ค. | |
| # ๊ทธ๋์ ๋ชจ๋ธ์ด ํ์ผ ์์ /์ด๋ฏธ์ง ๋ถ์ ์์ฒญ์์๋ ์ต๊ด์ ์ผ๋ก hugin_screenshot์ ํธ์ถํด | |
| # ์ฒจ๋ถ ์ด๋ฏธ์ง๋ฅผ ๋ฌด์ํ๊ณ 'Hugin ์ฐ๊ฒฐ ์ค๋ฅ' ํ๊ฐ์ ์ผ์ผํค๋ ๋ฌธ์ ๋ฅผ ๊ทผ๋ณธ ์ฐจ๋จํ๊ธฐ ์ํจ. | |
| _HUGIN_TOOL_NAMES = {"hugin_navigate", "hugin_screenshot", "hugin_click", "hugin_type"} | |
| # [๐ฑ Imperial Fix v2.7] ํ ์์/ํ๋๋ผ ๋์คํจ์น ๋๊ตฌ๋ ๊ธฐ๋ณธ ์ ์ธ. | |
| # ํ์ผ ๋ง๋ค๊ธฐ ๊ฐ์ ์ผ๋ฐ ์์ ์๋ ๋ชจ๋ธ์ด ์ต๊ด์ ์ผ๋ก delegate_team_task(ํ ํ ์คํฌ)/ | |
| # dispatch_to_hydra(ํ๋๋ผ)๋ฅผ ํธ์ถํด ์คํจํ๋ฉฐ ํ๋ฉด์ ์ด์ง๋ฝํ๋ ๋ฌธ์ ๋ฅผ ๋ง๋๋ค. | |
| # ๋ง์๋์ด 'ํ ํ ์คํฌ/์์/ํ๋๋ผ'๋ฅผ ๋ช ์์ ์ผ๋ก ์์ฒญํ ๋๋ง ๋ ธ์ถํ๋ค. | |
| _DELEGATION_TOOL_NAMES = {"delegate_team_task", "dispatch_to_hydra"} | |
| # [๐ฑ Imperial Fix v2.7] OS ์ฑ ์คํ/ํค์ ๋ ฅ ๋๊ตฌ๋ ๊ธฐ๋ณธ ์ ์ธ. | |
| # ํ์ผ ๋ง๋ค๊ธฐ์ notepad/cmd๋ฅผ ์คํํ๋ ค๋ค ์คํจํ๋ฉฐ ํ๋ฉด์ ์ด์ง๋ฝํ๋ ๋ฌธ์ ์ฐจ๋จ. | |
| _OS_CONTROL_TOOL_NAMES = {"sovereign_run_app", "sovereign_type_text", "sovereign_press_key"} | |
| include_hugin = self._wants_hugin(prompt) | |
| include_delegation = self._wants_delegation(prompt) | |
| include_os_control = self._wants_os_control(prompt) | |
| # ๐๏ธ [Guardian Job M1] ์ง์ ๊ธฐ๋ฐ ๋๊ตฌ ํ์ดํธ๋ฆฌ์คํธ. | |
| # ์ด ์ํธ์์ capabilities.tool_mode == 'whitelist' ์ด๋ฉด, allowed_tools์ | |
| # ๋งค์นญ๋๋ ๋๊ตฌ๋ง ๋ ธ์ถํ๋ค(์ง์ ํนํ). 'all'/๋ฏธ์ค์ ์ด๋ฉด ๊ธฐ์กด ๊ฒ์ดํธ ๋์ ์ ์ง. | |
| _cfg = getattr(self, "dynamic_config", None) or {} | |
| _tool_mode = (_cfg.get("tool_mode") or "all").lower() | |
| _allowed_tools = _cfg.get("allowed_tools") or [] | |
| _use_whitelist = (_tool_mode == "whitelist") and bool(_allowed_tools) | |
| # ํ์ดํธ๋ฆฌ์คํธ ๋ชจ๋๋ผ๋ ํญ์ ๋ณด์ฅํ๋ ๋ฒ ์ด์ค ๊ณต์ฉ ๋๊ตฌ (์ผ์ ์์ ๋ชป ํ๊ฒ ๋๋ ๊ฒ ๋ฐฉ์ง) | |
| _BASE_TOOLS = {"jarvis_memory", "recall_memory", "count_history", "search_knowledge", "save_knowledge", "check_port", "find_process", "ask_user"} | |
| # ๐ฑ [ํต์ผ] MCP ํ๋ ์ด์ ํ๋๊ทธ (OpenAI ๊ฒฝ๋ก์ ๋์ผ ๊ท์น) | |
| _mcp_flags = { | |
| "hugin": include_hugin, | |
| "search": self._wants_web_search(prompt), | |
| "coding": self._wants_coding(prompt), | |
| } | |
| unified_function_declarations = [] | |
| for t in self._tools: | |
| _tn = getattr(t, "__name__", "") | |
| # ๐ฑ [ํต์ผ] MCP ๋๊ตฌ: ๋ค์ดํฐ๋ธ ์ค๋ณต ์ ๊ฑฐ + ๋ฌด๊ฑฐ์ด ๊ทธ๋ฃน ์๋ ๊ฒ์ดํธ (๋ชจ๋ ๊ฒฝ๋ก ๊ณตํต) | |
| if _tn.startswith("mcp_") and not self._mcp_name_kept(_tn, _mcp_flags): | |
| continue | |
| if _use_whitelist: | |
| # ์ง์ ํ์ดํธ๋ฆฌ์คํธ: ํ์ฉ๋ชฉ๋ก ๋งค์นญ or ๋ฒ ์ด์ค๋๊ตฌ๋ง ํต๊ณผ | |
| if _tn not in _BASE_TOOLS and not self._tool_allowed(_tn, _allowed_tools): | |
| continue | |
| else: | |
| # ๊ธฐ์กด ์์ ๊ฒ์ดํธ (Hugin / ์์ / OS์ ์ด๋ ๋ช ์ ์์ฒญ ์์๋ง) | |
| if not include_hugin and _tn in _HUGIN_TOOL_NAMES: | |
| continue | |
| if not include_delegation and _tn in _DELEGATION_TOOL_NAMES: | |
| continue | |
| if not include_os_control and _tn in _OS_CONTROL_TOOL_NAMES: | |
| continue | |
| unified_function_declarations.append(t) | |
| if _use_whitelist: | |
| _exposed = [getattr(t, "__name__", "?") for t in unified_function_declarations] | |
| logger.info(f"๐๏ธ [Guardian Job] ํ์ดํธ๋ฆฌ์คํธ ๋ชจ๋ โ ๋ ธ์ถ ๋๊ตฌ {len(_exposed)}๊ฐ: {_exposed}") | |
| else: | |
| if include_hugin: | |
| logger.info("๐ฑ [Imperial] ๋ช ์์ ๋ธ๋ผ์ฐ์ ์ ์ด ์์ฒญ ๊ฐ์ง โ Hugin ๋๊ตฌ๋ฅผ ๋๊ตฌ ์ธํธ์ ํฌํจํ์ต๋๋ค.") | |
| else: | |
| logger.info("๐ฑ [Imperial] Hugin ๋๊ตฌ๋ ๊ธฐ๋ณธ ์ ์ธ ์ํ (๋ช ์์ ๋ธ๋ผ์ฐ์ ์์ฒญ ์์).") | |
| if include_delegation: | |
| logger.info("๐ฑ [Imperial] ๋ช ์์ ํ ์์/ํ๋๋ผ ์์ฒญ ๊ฐ์ง โ ์์ ๋๊ตฌ๋ฅผ ๋๊ตฌ ์ธํธ์ ํฌํจํ์ต๋๋ค.") | |
| else: | |
| logger.info("๐ฑ [Imperial] ํ ์์/ํ๋๋ผ ๋๊ตฌ๋ ๊ธฐ๋ณธ ์ ์ธ ์ํ (๋ช ์์ ์์ ์์ฒญ ์์).") | |
| if include_os_control: | |
| logger.info("๐ฑ [Imperial] ๋ช ์์ OS ์ฑ ์คํ/ํค์ ๋ ฅ ์์ฒญ ๊ฐ์ง โ OS ์ ์ด ๋๊ตฌ๋ฅผ ํฌํจํ์ต๋๋ค.") | |
| else: | |
| logger.info("๐ฑ [Imperial] OS ์ ์ด(notepad/cmd ๋ฑ) ๋๊ตฌ๋ ๊ธฐ๋ณธ ์ ์ธ ์ํ (๋ช ์์ ์คํ ์์ฒญ ์์).") | |
| # ๐๏ธ [Guardian Job] ์ง์ ๊ธฐ๋ณธ ๋ฃฐ ์ ์ฉ. | |
| # ์ํธ์ ๊ฐ๋ณ ๋ฃฐ(relay_system_prompt)์ ์ด๋ฏธ system_instruction์ ๋ฐ์๋ผ ์๋ค. | |
| # ๊ฐ๋ณ ๋ฃฐ์ด (๊ฑฐ์) ๋น์ด ์์ ๋๋ง, ์ง์ ์ ๊ธฐ๋ณธ ๋ฃฐ(job_rule)์ ์์คํ ์ง์์ ๋ง๋ถ์ธ๋ค. | |
| # โ "์ง์ ํ๋ ๊ณ ๋ฅด๋ฉด ๋ฃฐ๊น์ง ์๋ ์ ํ" + "๊ฐ๋ณ ๋ฃฐ ์ฐ๋ฉด ๊ทธ๊ฒ ์ฐ์ " ๋ ๋ค ๋ง์กฑ. | |
| try: | |
| _job_rule = (_cfg.get("job_rule") or "").strip() | |
| if _job_rule: | |
| _has_custom_rule = bool(system_instruction and len(system_instruction.strip()) > 80) | |
| if not _has_custom_rule: | |
| system_instruction = (system_instruction or "") + f"\n\n## ๐๏ธ ์ง์ ์ง์นจ\n{_job_rule}" | |
| logger.info("๐๏ธ [Guardian Job] ์ง์ ๊ธฐ๋ณธ ๋ฃฐ์ ์์คํ ์ง์์ ์ ์ฉํ์ต๋๋ค.") | |
| else: | |
| logger.info("๐๏ธ [Guardian Job] ์ํธ์ ๊ฐ๋ณ ๋ฃฐ์ด ์์ด ์ง์ ๊ธฐ๋ณธ ๋ฃฐ์ ์๋ตํฉ๋๋ค.") | |
| except Exception as _jr_err: | |
| logger.warning(f"[Guardian Job] ์ง์ ๋ฃฐ ์ ์ฉ ์คํจ(๋ฌด์): {_jr_err}") | |
| # ๐๏ธ [Guardian Job M2] ์ฅ์ฐฉ ์คํฌ(์ ๋ฌธ ์ง์)์ ์์คํ ์ง์์ ์ฃผ์ . | |
| # capabilities.equipped_skills ์ ์ ํ ์คํฌ ๋ฌธ์๋ฅผ ์ฝ์ด system_instruction ๋ค์ ๋ง๋ถ์ธ๋ค. | |
| try: | |
| _equipped = _cfg.get("equipped_skills") or [] | |
| if _equipped: | |
| _skill_text = self._load_equipped_skills(_equipped) | |
| if _skill_text: | |
| system_instruction = (system_instruction or "") + _skill_text | |
| yield f"[STAGE:๐ ์คํฌ ์ฅ์ฐฉ: {' ยท '.join(str(x) for x in _equipped[:4])}]\n" | |
| except Exception as _skill_err: | |
| logger.warning(f"[Guardian Job M2] ์คํฌ ์ฃผ์ ์คํจ(๋ฌด์): {_skill_err}") | |
| # ๐ [Hybrid Route A] ๊ฒ์ ์ง๋ฌธ: ๊ตฌ๊ธ ๊ทธ๋ผ์ด๋ฉ ๋๋ Brave Search ์ฃผ์ | |
| # [๐ฑ Imperial Fix] Multimodal(Images)๊ณผ Grounding ๋๊ตฌ๋ ๋์ ์ฌ์ฉ ์ ์ถฉ๋ ๊ฐ๋ฅ์ฑ์ด ํฌ๋ฏ๋ก ์ด๋ฏธ์ง๊ฐ ์์ ๋ ๋นํ์ฑํ | |
| if is_pure_search: | |
| logger.info("๐ [IMPERIAL BYPASS] Google Search Grounding ์์ REST API ์ง์ ํธ์ถ ์ง์ ...") | |
| try: | |
| # 1. API key ํ๋ | |
| api_key = getattr(self, "api_key", None) | |
| if not api_key: | |
| api_key = os.environ.get('GEMINI_API_KEY', '') or os.environ.get('GOOGLE_API_KEY', '') | |
| if not api_key and getattr(self, "_clients", None): | |
| for k in self._clients.keys(): | |
| if k: | |
| api_key = k | |
| break | |
| if not api_key: | |
| raise ValueError("GEMINI_API_KEY๊ฐ ์์ต๋๋ค.") | |
| # 2. REST API ํธ์ถ | |
| url = f"https://generativelanguage.googleapis.com/v1beta/models/{actual_model_name}:generateContent?key={api_key}" | |
| payload = { | |
| "contents": [{"parts": [{"text": prompt}]}], | |
| "tools": [{"google_search": {}}], | |
| "generationConfig": { | |
| "temperature": 0.0 | |
| } | |
| } | |
| if system_instruction: | |
| payload["systemInstruction"] = { | |
| "parts": [{"text": system_instruction}] | |
| } | |
| logger.info(f"๐ก REST API POST request to models/{actual_model_name}...") | |
| import requests as _req | |
| resp = _req.post(url, json=payload, timeout=30) | |
| resp.raise_for_status() | |
| res_data = resp.json() | |
| candidate = res_data.get('candidates', [{}])[0] | |
| content = candidate.get('content', {}) | |
| parts = content.get('parts', []) | |
| text = parts[0].get('text', '') if parts else '' | |
| grounding_sources = [] | |
| grounding_metadata = candidate.get('groundingMetadata', {}) | |
| attributions = grounding_metadata.get('groundingAttributions', []) | |
| for attr in attributions: | |
| web = attr.get('web', {}) | |
| if web: | |
| grounding_sources.append(f"- [{web.get('title')}]({web.get('uri')})") | |
| if text: | |
| # [๐ฑ Imperial Post-Process] ๋ง์๋ ๊ทน์กด์นญ ๋ฐ ์ ์ฒด์ฑ ์ธ๊ฒฉ ํฉ์ฑ | |
| prefix = "๋ง์๋, " if is_master else "" | |
| text = f"{prefix}์ค์๊ฐ ๊ตฌ๊ธ ๊ฒ์ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํ์ผ๋ก ๋ณด๊ณ ๋๋ฆฝ๋๋ค.\n\n{text}" | |
| # ์ฒญํฌ ๋จ์ yield๋ก ์คํธ๋ฆฌ๋ฐ ํจ๊ณผ ๋ถ์ฌ | |
| chunk_size = 100 | |
| for i in range(0, len(text), chunk_size): | |
| yield text[i:i+chunk_size] | |
| if grounding_sources: | |
| sources_header = "\n\n---\n**๐ฑ [์ ๊ตญ ์ ์ฐฐ ์ ๋ณด์ / Imperial Recon Intel]**\n" | |
| sources_text = sources_header + "\n".join(grounding_sources) | |
| yield sources_text | |
| else: | |
| yield "โ ๏ธ [๋ธ๋ ์ธ ๋ฌด์๋ต] ๊ฒ์ ๊ฒฐ๊ณผ๋ฅผ ํ์ฑํ์ง ๋ชปํ์ต๋๋ค." | |
| return | |
| except Exception as rest_err: | |
| logger.error(f"๐ [IMPERIAL BYPASS] REST API Grounding failed: {rest_err}. Falling back to standard flow...") | |
| try: | |
| search_tool = types.Tool(google_search=types.GoogleSearch()) | |
| genai_tools = [search_tool] | |
| system_instruction = None | |
| except Exception as e: | |
| logger.error(f"Failed to load GoogleSearch tool: {e}") | |
| else: | |
| # ๐ฑ [Hybrid Route B] ๋น๊ฒ์ ์ง๋ฌธ: MCP + ์ปค์คํ ๋๊ตฌ ์ธํธ ์จ์ ์ฃผ์ | |
| # [๐ฑ Imperial Fix] SDK v1.x+ ์์๋ ํจ์ ๋ฆฌ์คํธ๋ฅผ ์ง์ ์ ๋ฌํ๋ ๊ฒ์ด ๋ ์์ ์ ์ (pydantic ๊ฒ์ฆ ์ค๋ฅ ๋ฐฉ์ง) | |
| genai_tools = unified_function_declarations if unified_function_declarations else None | |
| # ๐ฑ [Imperial Tool Force Mode] AFC ๊ธฐ๋ณธ ์ค์ | |
| afc_config = types.AutomaticFunctionCallingConfig(disable=False) | |
| # ๐ฑ [Imperial Task Mode] ์์ ํ ์์ฒญ ๊ฐ์ง โ ๋๊ตฌ ๊ฐ์ ํธ์ถ(mode=ANY) | |
| # gemini-flash-lite ๊ฒฝ๋ ๋ชจ๋ธ์ ์๋ฐ์ ์ผ๋ก ๋๊ตฌ๋ฅผ ์ ์ฐ๋ ๊ฒฝํฅ์ด ์์ผ๋ฏ๋ก | |
| # ํ์ผ/๋ฒ๊ทธ/UI/์ด๋ฏธ์ง ์์ฒญ์์๋ ๋ฐ๋์ MCP ๋๊ตฌ๋ฅผ ๋จผ์ ํธ์ถํ๋๋ก API ๋ ๋ฒจ์์ ๊ฐ์ ํฉ๋๋ค. | |
| tool_config = None | |
| _diag_is_task = False | |
| if not is_pure_search and genai_tools: | |
| _diag_is_task = self._is_task_request(prompt, images) | |
| if _diag_is_task: | |
| try: | |
| tool_config = types.ToolConfig( | |
| function_calling_config=types.FunctionCallingConfig( | |
| mode="ANY" # ๋ชจ๋ธ์ด ๋ฐ๋์ ๋๊ตฌ ์ค ํ๋๋ฅผ ํธ์ถํด์ผ ํจ | |
| ) | |
| ) | |
| logger.info("๐ง [Imperial Task Mode] ์์ ํ ์์ฒญ ๊ฐ์ง โ ๋๊ตฌ ๊ฐ์ ํธ์ถ ๋ชจ๋(ANY) ํ์ฑํ") | |
| except Exception as tc_err: | |
| logger.warning(f"[Task Mode] ToolConfig ์์ฑ ์คํจ, ์๋ ๋ชจ๋ ์ ์ง: {tc_err}") | |
| tool_config = None | |
| # [๐ฑ Imperial Diag v2.7] ํ๊ฐ ์ง๋จ: ์์ฒญ๋ณ๋ก ๋๊ตฌ ์ธํธ/์์ ํ์ /๊ฐ์ ๋ชจ๋๋ฅผ ๊ธฐ๋ก. | |
| # mcp_tool_calls.log์ ์ค์ CALL ๊ธฐ๋ก๊ณผ ๋์กฐํ๋ฉด, ๋ชจ๋ธ์ด '๋๊ตฌ๋ฅผ ๋ฐ๊ณ ๋ ์ ๋ถ๋ ๋์ง' | |
| # vs '๋ฐ์ง๋ ๋ชปํ๋์ง'๋ฅผ ๊ฐ๊ด์ ์ผ๋ก ๊ฐ๋ฆฐ๋ค. | |
| try: | |
| import datetime as _dt | |
| _n_tools = len(genai_tools) if isinstance(genai_tools, list) else 0 | |
| _diag = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scratch", "mcp_tool_calls.log") | |
| os.makedirs(os.path.dirname(_diag), exist_ok=True) | |
| with open(_diag, "a", encoding="utf-8") as _f: | |
| _f.write( | |
| f"[{_dt.datetime.now().isoformat()}] THINK model={actual_model_name} " | |
| f"prompt='{(prompt or '')[:50]}' images={bool(images)} " | |
| f"is_task={_diag_is_task} force_ANY={tool_config is not None} tools_in_set={_n_tools}\n" | |
| ) | |
| except Exception: | |
| pass | |
| # [๐ฑ Imperial V12] Unified Context Construction (History + Current Prompt) | |
| contents = [] | |
| if history and isinstance(history, list): | |
| logger.info(f"๐ฑ [Neural Core] Injecting provided history context: {len(history)} entries.") | |
| for msg in history: | |
| role = msg.get("role", "user") | |
| content = msg.get("content", "") | |
| if content: | |
| contents.append(types.Content(role=role, parts=[types.Part(text=content)])) | |
| # Add current prompt | |
| current_parts = [types.Part(text=prompt)] | |
| if images: | |
| logger.info(f"๐ฑ [Neural Core] Multimodal Input Detected: Processing {len(images)} images...") | |
| for img in images: | |
| try: | |
| # [๐ฑ] data:image/jpeg;base64, ์ ๋์ด๊ฐ ์๋ ๊ฒฝ์ฐ ์ ๊ฑฐ (Heimdall์ ์ด๋ฏธ ์ ๊ฑฐํด์ ๋ณด๋ด์ง๋ง ๋ฐฉ์ด์ ์ฝ๋ ์ถ๊ฐ) | |
| img_data = img['data'] | |
| if isinstance(img_data, str) and ',' in img_data: | |
| img_data = img_data.split(',')[1] | |
| current_parts.append(types.Part.from_bytes( | |
| data=base64.b64decode(img_data), | |
| mime_type=img.get("mimeType", "image/jpeg") | |
| )) | |
| except Exception as img_err: | |
| logger.error(f"Image Processing Error: {str(img_err)}") | |
| yield f"โ ๏ธ [Image Processing Error] {str(img_err)}" | |
| contents.append(types.Content(role="user", parts=current_parts)) | |
| try: | |
| ImperialQuotaManager.validate_quota(actual_model_name) | |
| except QuotaExceededError as qe: | |
| yield f"\n{str(qe)}" | |
| return | |
| # [๐ฑ Imperial AFC & Grounding Fix v3.0] | |
| # ๊ตฌ๊ธ ๊ฒ์ ๊ทธ๋ผ์ด๋ฉ(is_pure_search) ๋ฐ ์ปค์คํ ๋๊ตฌ(MCP) ์ฌ์ฉ ์ Non-Streaming ๊ฐ์ | |
| use_afc_non_streaming = ( | |
| is_pure_search or | |
| (genai_tools and not isinstance(genai_tools[0], types.Tool)) | |
| ) | |
| full_text = "" | |
| grounding_sources = [] | |
| self._afc_tool_results.clear() | |
| # ๐ [Token Streaming + Manual FC] stream_tokens ์ตํธ์ธ (ํ๋ฏธ๋ CLI send-stream ๋ฑ): | |
| # ์ปค์คํ ๋๊ตฌ(callables)๋ ์๋ AFC ๋น์คํธ๋ฆฌ๋ฐ์ผ๋ก ๊ฐ์ ๋์ด ์๋ต์ด ํ ๋ฉ์ด๋ฆฌ๋ก | |
| # ๋์๋ค. ์ด ๋ถ๊ธฐ๋ AFC๋ฅผ ๋๊ณ '์๋ ํจ์ํธ์ถ ๋ฃจํ'๋ฅผ ๋๋ฉฐ ํ ์คํธ ํ ํฐ์ ์ฆ์ | |
| # ๋ฐฉ์ถํ๋ค. ์คํจ ์ ๊ธฐ์กด(AFC ๋น์คํธ๋ฆฌ๋ฐ) ๊ฒฝ๋ก๋ก ๊ทธ๋๋ก ํด๋ฐฑ โ ๊ธฐ์กด ํธ์ถ์ ๋ฌด์ํฅ. | |
| if (stream_tokens and use_afc_non_streaming and not is_pure_search | |
| and genai_tools and not isinstance(genai_tools[0], types.Tool)): | |
| try: | |
| yield from self._gemini_manual_stream_loop( | |
| client, actual_model_name, contents, genai_tools, | |
| system_instruction, tool_config, | |
| ) | |
| return | |
| except Exception as _ms_err: | |
| logger.warning(f"๐ [TokenStream] ์๋ ์คํธ๋ฆผ ๋ฃจํ ์คํจ โ AFC ๋น์คํธ๋ฆฌ๋ฐ ํด๋ฐฑ: {_ms_err}") | |
| if use_afc_non_streaming: | |
| logger.info(f"๐ฑ [Neural Core][AFC-NonStream] Calling generate_content (non-streaming) with model={actual_model_name}") | |
| try: | |
| _gen_config_args = dict( | |
| system_instruction=system_instruction, | |
| tools=genai_tools, | |
| temperature=0.0, | |
| automatic_function_calling=None if is_pure_search else afc_config, | |
| safety_settings=[ | |
| types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_CIVIC_INTEGRITY", threshold="BLOCK_NONE") | |
| ] | |
| ) | |
| # ๐ฑ [Task Mode] ๋๊ตฌ ๊ฐ์ ํธ์ถ ์ค์ ์ฃผ์ | |
| if tool_config is not None: | |
| _gen_config_args["tool_config"] = tool_config | |
| response = client.models.generate_content( | |
| model=actual_model_name, | |
| contents=contents, | |
| config=types.GenerateContentConfig(**_gen_config_args) | |
| ) | |
| self._emit_token_usage("google", actual_model_name, getattr(response, "usage_metadata", None)) # ๐ช | |
| try: | |
| text = response.text or "" | |
| except Exception: | |
| text = "" | |
| if not text and hasattr(response, 'candidates') and response.candidates: | |
| cand = response.candidates[0] | |
| if hasattr(cand, 'content') and hasattr(cand.content, 'parts'): | |
| for part in cand.content.parts: | |
| if hasattr(part, 'text') and part.text: | |
| text += part.text | |
| # [๐ฑ Grounding Metadata Parsing for Non-Streaming] | |
| if is_pure_search and response.candidates: | |
| cand = response.candidates[0] | |
| if hasattr(cand, 'grounding_metadata') and cand.grounding_metadata: | |
| gm = cand.grounding_metadata | |
| if hasattr(gm, 'grounding_chunks') and gm.grounding_chunks: | |
| for chunk in gm.grounding_chunks: | |
| web = getattr(chunk, 'web', None) | |
| if web: | |
| title = getattr(web, 'title', 'Untitled') | |
| uri = getattr(web, 'uri', '#') | |
| grounding_sources.append(f"- [{title}]({uri})") | |
| if text: | |
| # [๐ฑ Imperial Post-Process] ๊ตฌ๊ธ ์ค์๊ฐ ๊ฒ์ ์๋ฃ ํ, ๋ง์๋ ๊ทน์กด์นญ ๋ฐ ๊ทธ๋ฆผ์ ๋๋ ์ ์ฒด์ฑ ์ธ๊ฒฉ ํฉ์ฑ | |
| if is_pure_search: | |
| if is_master: | |
| if not text.strip().startswith("๋ง์๋"): | |
| text = f"๋ง์๋, ์ค์๊ฐ ๊ตฌ๊ธ ๊ฒ์ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํ์ผ๋ก ๋ณด๊ณ ๋๋ฆฝ๋๋ค.\n\n{text}" | |
| else: | |
| if not text.strip().startswith("์ค์๊ฐ"): | |
| text = f"์ค์๊ฐ ๊ตฌ๊ธ ๊ฒ์ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํ์ผ๋ก ๋ณด๊ณ ๋๋ฆฝ๋๋ค.\n\n{text}" | |
| logger.info(f"๐ฑ [Neural Core][AFC-NonStream] Response text len={len(text)}") | |
| full_text = text | |
| # ์ฒญํฌ์ฒ๋ผ ๋๋ ์ yield (์์ฐ์ค๋ฌ์ด ์คํธ๋ฆฌ๋ฐ ํจ๊ณผ) | |
| chunk_size = 100 | |
| for i in range(0, len(text), chunk_size): | |
| yield text[i:i+chunk_size] | |
| # [๐ฑ Grounding Sources Display] | |
| if grounding_sources: | |
| sources_header = "\n\n---\n**๐ฑ [์ ๊ตญ ์ ์ฐฐ ์ ๋ณด์ / Imperial Recon Intel]**\n" | |
| sources_text = sources_header + "\n".join(grounding_sources) | |
| yield sources_text | |
| # [๐ฑ Image Attach Fix] ๋ชจ๋ธ์ด ํ ์คํธ ์์ฝ์ ๋ง๋ค๋ฉด์ ์ด๋ฏธ์ง ์์ฑ ๊ฒฐ๊ณผ(๋งํฌ๋ค์ด )๋ฅผ | |
| # ๋๋ฝํ๋ ๊ฒฝ์ฐ๊ฐ ์ฆ๋ค. ๋๊ตฌ ๊ฒฐ๊ณผ์ ์ด๋ฏธ์ง๊ฐ ์๊ณ ๋ณธ๋ฌธ์ ์์ง ์์ผ๋ฉด ๊ฐ์ ๋ก ์ฒจ๋ถํ์ฌ | |
| # ์ฑํ ์ ์ด๋ฏธ์ง๊ฐ ํญ์ ํ์๋๊ฒ ํ๋ค. (generate_image ๋ฑ) | |
| for _r in self._afc_tool_results: | |
| if "![" in str(_r) and str(_r) not in full_text: | |
| yield "\n\n" + _r | |
| full_text += "\n\n" + _r | |
| elif self._afc_tool_results: | |
| logger.info(f"[AFC-NonStream] Tool results found: {len(self._afc_tool_results)}") | |
| for result in self._afc_tool_results: | |
| full_text += result + "\n" | |
| yield result + "\n" | |
| else: | |
| logger.warning(f"[AFC-NonStream] Empty response from '{actual_model_name}'. Triggering fallback.") | |
| if not skip_fallback: | |
| yield from self._think_fallback(prompt, system_instruction=system_instruction, skip_tools=skip_tools, tools=None) | |
| else: | |
| yield "โ ๏ธ [๋ธ๋ ์ธ ๋ฌด์๋ต] ๋ชจ๋ธ์ด ์๋ต์ ์์ฑํ์ง ๋ชปํ์ต๋๋ค." | |
| self._afc_tool_results.clear() | |
| return | |
| except Exception as afc_e: | |
| logger.warning(f"[AFC-NonStream] Exception: {afc_e}. Falling back to streaming mode.") | |
| # ์คํจ ์ ์๋ ์คํธ๋ฆฌ๋ฐ ๋ชจ๋๋ก ๊ณ์ ์งํ | |
| logger.info(f"๐ฑ [Neural Core] Calling generate_content_stream with model={actual_model_name}") | |
| _stream_config_args = dict( | |
| system_instruction=system_instruction, | |
| tools=genai_tools, | |
| temperature=0.0, | |
| automatic_function_calling=afc_config, | |
| safety_settings=[ | |
| types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="BLOCK_NONE"), | |
| types.SafetySetting(category="HARM_CATEGORY_CIVIC_INTEGRITY", threshold="BLOCK_NONE") | |
| ] | |
| ) | |
| # ๐ฑ [Task Mode] ์คํธ๋ฆฌ๋ฐ ๊ฒฝ๋ก์๋ ๋๊ตฌ ๊ฐ์ ํธ์ถ ์ค์ ์ฃผ์ | |
| if tool_config is not None: | |
| _stream_config_args["tool_config"] = tool_config | |
| response_stream = client.models.generate_content_stream( | |
| model=actual_model_name, | |
| contents=contents, | |
| config=types.GenerateContentConfig(**_stream_config_args) | |
| ) | |
| logger.info("๐ฑ [Neural Core] response_stream obtained. Entering iteration loop...") | |
| _usage_acc = None # ๐ช ํ ํฐ ์ฌ์ฉ๋(๋์ ์ฒญํฌ) | |
| for chunk in response_stream: | |
| logger.info(f"๐ฑ [Neural Core] Chunk received (type={type(chunk)})") | |
| try: | |
| _um = getattr(chunk, "usage_metadata", None) | |
| if _um is not None: | |
| _usage_acc = _um | |
| # ๐ Extract Grounding Metadata (Sources) | |
| # Support both top-level and candidate-level metadata (SDK v1.x+) | |
| gm = None | |
| if hasattr(chunk, 'grounding_metadata') and chunk.grounding_metadata: | |
| gm = chunk.grounding_metadata | |
| elif hasattr(chunk, 'candidates') and chunk.candidates: | |
| cand = chunk.candidates[0] | |
| if hasattr(cand, 'grounding_metadata') and cand.grounding_metadata: | |
| gm = cand.grounding_metadata | |
| if gm: | |
| # [๐ฑ Imperial V11] Log raw grounding metadata for diagnostics | |
| try: | |
| diag_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "scratch", "grounding_diagnostics.log") | |
| os.makedirs(os.path.dirname(diag_path), exist_ok=True) | |
| with open(diag_path, "a", encoding="utf-8") as f: | |
| # Safe string conversion for complex SDK objects | |
| f.write(f"\n[{datetime.now().isoformat()}] metadata_len: {len(str(gm))}\n") | |
| except: pass | |
| # ๐ Extract Text (V16 Deep Padding) | |
| text = "" | |
| try: | |
| if hasattr(chunk, 'text') and chunk.text: | |
| text = chunk.text | |
| except: pass | |
| if not text and hasattr(chunk, 'candidates') and chunk.candidates: | |
| cand = chunk.candidates[0] | |
| if hasattr(cand, 'content') and hasattr(cand.content, 'parts'): | |
| for part in cand.content.parts: | |
| if hasattr(part, 'text'): | |
| text += part.text | |
| if text: | |
| logger.info(f"๐ฑ [Neural Core] Text extracted (len={len(text)})") | |
| full_text += text | |
| yield text | |
| else: | |
| logger.warning("๐ฑ [Neural Core] Chunk received without printable text.") | |
| except Exception as chunk_e: | |
| logger.warning(f"๐ฑ [Neural Core] Chunk processing bypass: {chunk_e}") | |
| continue | |
| self._emit_token_usage("google", actual_model_name, _usage_acc) # ๐ช | |
| # ๐ Append Citations at the end if found | |
| if grounding_sources: | |
| sources_header = "\n\n---\n**๐ฑ [์ ๊ตญ ์ ์ฐฐ ์ ๋ณด์ / Imperial Recon Intel]**\n" | |
| sources_text = sources_header + "\n".join(grounding_sources) | |
| # ๐ ๏ธ [๐ฑ Logic] Add instruction to check logs | |
| log_hint = "\n\n> [!TIP]\n> ์์ธ ๊ฒ์ ๊ทผ๊ฑฐ์ AI๊ฐ ์ฐธ์กฐํ ์๋ฌธ ์ค๋ํซ์ `shadow_brain_core/scratch/grounding_diagnostics.log`์์ ํ์ธํ์ค ์ ์์ต๋๋ค." | |
| sources_text += log_hint | |
| full_text += sources_text | |
| yield sources_text | |
| if not full_text: | |
| if self._afc_tool_results: | |
| # Model executed tools but didn't generate summary text (common with lite models) | |
| logger.info(f"[AFC Fallback] Returning {len(self._afc_tool_results)} tool result(s) as response.") | |
| for result in self._afc_tool_results: | |
| yield result + "\n" | |
| else: | |
| # Empty response with no tool results โ lite model failure, trigger fallback | |
| logger.warning(f"Empty response from '{actual_model_name}' with no tool results. Triggering fallback.") | |
| if not skip_fallback: | |
| yield from self._think_fallback(prompt, system_instruction=system_instruction, skip_tools=skip_tools, tools=None) | |
| else: | |
| yield "โ ๏ธ [๋ธ๋ ์ธ ๋ฌด์๋ต] ๋ชจ๋ธ์ด ์๋ต์ ์์ฑํ์ง ๋ชปํ์ต๋๋ค. ๋ค๋ฅธ ๋ชจ๋ธ์ ์ ํํ๊ฑฐ๋ ๋ค์ ์๋ํด์ฃผ์ธ์." | |
| self._afc_tool_results.clear() | |
| except Exception as e: | |
| err_msg = str(e) | |
| import traceback | |
| logger.error(f"Brain Error: {err_msg}\n{traceback.format_exc()}") | |
| # [๐ฑ Imperial Fix] ๋ง์ฝ ๋๊ตฌ/๊ฒ์(Grounding) ๋ฌธ์ ๋ก ์๋ฌ๊ฐ ๋ฌ๋ค๋ฉด, ๋๊ตฌ ์์ด ์ฌ์๋ (Fallback ๋ฐฉ์ง) | |
| logger.warning(f"Brain Error encountered: {err_msg}. Investigating for Tool Failure...") | |
| # 2026 SDK ๋ฒ์ ์์ 'google_search'๋ 'grounding' ๊ด๋ จ ํ๋ ์ฒ๋ฆฌ ์ค ์๋ฌ๊ฐ ์์ฃผ ๋ฐ์ํ๋ฏ๋ก ๋ฒ์๋ฅผ ๋ํ๋๋ค. | |
| # ํนํ 'finish_reason'์ด๋ 'candidate' ๊ด๋ จ ์ธ๋ฑ์ค ์๋ฌ๋ ๋๋ถ๋ถ ๋๊ตฌ ์๋ต ํ์ฑ ์คํจ์ ๋๋ค. | |
| is_search_error = any(kw in err_msg.lower() for kw in [ | |
| "tool", "search", "grounding", "function", "grounding_metadata", | |
| "candidate", "safety_ratings", "citation", "finish_reason", "index out of range" | |
| ]) | |
| if not skip_tools and genai_tools and is_search_error: | |
| if self.brave_api_key: | |
| logger.warning("๐ฑ [RECOVERY] Google Search Grounding failed. Bypassing to Brave Search API...") | |
| try: | |
| brave_results = self._search_brave(prompt) | |
| if brave_results: | |
| enriched_prompt = f"๋ค์ ์ค์๊ฐ Brave ๊ฒ์ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํ์ผ๋ก ์ง๋ฌธ์ ๋ํด ๋ช ํํ ๋ต๋ณํ๊ณ , ๋ต๋ณ ๋์ ๋ฐ๋์ ํด๋น ๊ฒ์ ์ถ์ฒ URL ๋งํฌ๋ ๋ณด๊ธฐ ์ข๊ฒ ํฌํจํ์ญ์์ค.\n\n{brave_results}\n\n์ง๋ฌธ: {prompt}" | |
| yield from self.think(enriched_prompt, model_name=model_name, images=images, | |
| system_instruction=system_instruction, skip_fallback=skip_fallback, | |
| provider=provider, skip_tools=True, session_id=session_id) | |
| return | |
| except Exception as brave_err: | |
| logger.error(f"Brave Fallback failed: {brave_err}") | |
| logger.warning(f"๐ฑ [RECOVERY] Google Search/Tool failure detected ({err_msg}). Retrying WITHOUT tools to save Mission...") | |
| yield from self.think(prompt, model_name=model_name, images=images, | |
| system_instruction=system_instruction, skip_fallback=skip_fallback, | |
| provider=provider, skip_tools=True, session_id=session_id) | |
| return | |
| if skip_fallback: | |
| yield f"\nโ Critical Brain Malfunction (Primary/Secondary failed): {err_msg}" | |
| else: | |
| logger.warning(f"Imperial Brain Malfunction: {err_msg}. Fail-Fast triggered.") | |
| yield f"\nโ [Google API ์๋ฌ] ๋ชจ๋ธ ํธ์ถ์ ์คํจํ์ต๋๋ค: {err_msg}" | |
| def _search_brave(self, query: str) -> str: | |
| """ | |
| Brave Search API๋ฅผ ํตํด ์ค์๊ฐ ์น ์ ๋ณด๋ฅผ ์กฐํํ๊ณ ๋งํฌ๋ค์ด ํํ๋ก ๋ฐํํฉ๋๋ค. | |
| """ | |
| if not self.brave_api_key: | |
| return "" | |
| logger.info(f"๐ก Querying Brave Search API for: {query}") | |
| headers = { | |
| "Accept": "application/json", | |
| "Accept-Encoding": "gzip", | |
| "X-Subscription-Token": self.brave_api_key, | |
| "Cache-Control": "no-cache" | |
| } | |
| params = { | |
| "q": query, | |
| "count": 5 | |
| } | |
| url = "https://api.search.brave.com/res/v1/web/search" | |
| try: | |
| response = requests.get(url, headers=headers, params=params, timeout=10) | |
| response.raise_for_status() | |
| data = response.json() | |
| results = data.get("web", {}).get("results", []) | |
| if not results: | |
| return "" | |
| md_list = [] | |
| md_list.append("\n=== [REAL-TIME BRAVE WEB SEARCH RESULTS] ===") | |
| for idx, item in enumerate(results, 1): | |
| title = item.get("title", "") | |
| url_val = item.get("url", "") | |
| snippet = item.get("description", "") | |
| md_list.append(f"[{idx}] {title}\nURL: {url_val}\nSnippet: {snippet}\n") | |
| md_list.append("============================================\n") | |
| return "\n".join(md_list) | |
| except Exception as e: | |
| logger.error(f"Brave Search API request failed: {e}") | |
| return "" | |
| def direct_search(self, query: str, model_name: str = None) -> Dict[str, Any]: | |
| """ | |
| [๐ฑ Imperial High-Performance Search] | |
| AI ๊ฒ์ ๋์ฐ๋ฏธ์ 100% ๋์ผํ ๋ก์ง์ ์๋์ฐ ๋ธ๋ ์ธ ์์ง ๋ด๋ถ์์ ์ํํฉ๋๋ค. | |
| ์คํธ๋ฆฌ๋ฐ์ด ์๋ ํ ๋ฒ์ ํธ์ถ๋ก ๊ฒฐ๊ณผ์ ์ถ์ฒ๋ฅผ ์ฆ์ ๋ฐํํฉ๋๋ค. | |
| """ | |
| if model_name is None: | |
| # [๐ฑ Imperial Fix] Use configured primary model or safe fallback | |
| model_name = getattr(self, "primary_model", "gemini-3.1-flash-lite") | |
| target_model = self._resolve_actual_model("google", model_name) | |
| try: | |
| # [๐ฑ Imperial Fix] google-genai SDK๋ฅผ ์ฌ์ฉํ์ฌ ๋ค์ด๋ ํธ ํธ์ถ ์ํ | |
| # generate_content_stream์ด ์๋ generate_content ์ฌ์ฉ | |
| search_tool = types.Tool(google_search=types.GoogleSearch()) | |
| client = self._get_client() # Default client | |
| response = client.models.generate_content( | |
| model=target_model, | |
| contents=query, | |
| config=types.GenerateContentConfig( | |
| tools=[search_tool], | |
| temperature=0.0, | |
| system_instruction="๋น์ ์ ์ ๊ตญ์ ์ง๋ฅ ์ ์ฐฉ๋ณ์ ๋๋ค. ํ๊ตญ์ด๋ก ๋ต๋ณํ์ญ์์ค. ๊ฒ์ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํ์ผ๋ก ํฉํธ ์์ฃผ๋ก ๋ณด๊ณ ํ์ญ์์ค." | |
| ) | |
| ) | |
| text = response.text or "" | |
| sources = [] | |
| # Extract grounding metadata | |
| if response.candidates and response.candidates[0].grounding_metadata: | |
| gm = response.candidates[0].grounding_metadata | |
| if hasattr(gm, 'grounding_chunks') and gm.grounding_chunks: | |
| for chunk in gm.grounding_chunks: | |
| web = getattr(chunk, 'web', None) | |
| if web: | |
| sources.append({ | |
| "title": getattr(web, "title", "Untitled"), | |
| "uri": getattr(web, "uri", "#") | |
| }) | |
| return { | |
| "status": "SUCCESS", | |
| "text": text, | |
| "sources": sources, | |
| "model": target_model | |
| } | |
| except Exception as e: | |
| logger.error(f"[Turbo Search Error] {e}") | |
| if self.brave_api_key: | |
| logger.info("๐ฑ [RECOVERY] Google Search failed. Attempting fallback to Brave Search API...") | |
| try: | |
| brave_results = self._search_brave(query) | |
| if brave_results: | |
| client = self._get_client() | |
| response = client.models.generate_content( | |
| model=target_model, | |
| contents=f"๋ค์ ์ง๋ฌธ์ ๋ํด ์ ๊ณต๋ Brave ๊ฒ์ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํ์ผ๋ก ๋ต๋ณํ์ญ์์ค.\n์ง๋ฌธ: {query}\n\n{brave_results}", | |
| config=types.GenerateContentConfig( | |
| temperature=0.0, | |
| system_instruction="๋น์ ์ ์ ๊ตญ์ ์ง๋ฅ ์ ์ฐฉ๋ณ์ ๋๋ค. ํ๊ตญ์ด๋ก ๋ต๋ณํ์ญ์์ค. ์ ๊ณต๋ ๊ฒ์ ๊ฒฐ๊ณผ ์๋ฃ๋ฅผ ์๋ฒฝํ ํ์ฉํด ๋ต๋ณ ๋์ ์ถ์ฒ URL ๋งํฌ๋ ํฌํจํด ๋งํฌ๋ค์ด ํ์์ผ๋ก ๋ณด๊ณ ํ์ญ์์ค." | |
| ) | |
| ) | |
| # Parse out sources from brave_results | |
| sources = [] | |
| import re | |
| matches = re.findall(r"\[(\d+)\] (.*?)\nURL: (.*?)\n", brave_results) | |
| for m in matches: | |
| sources.append({ | |
| "title": m[1], | |
| "uri": m[2] | |
| }) | |
| return { | |
| "status": "SUCCESS", | |
| "text": response.text or "", | |
| "sources": sources, | |
| "model": target_model + " (Brave Fallback)" | |
| } | |
| except Exception as ex: | |
| logger.error(f"[Brave Fallback Error] {ex}") | |
| return { | |
| "status": "ERROR", | |
| "message": str(e) | |
| } | |