taemin1980's picture
๐Ÿ”ฑ Imperial Deployment: Shadow Brain Core ignition
6e3e432 verified
Raw
History Blame Contribute Delete
296 kB
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![์ƒ์„ฑ๋œ ์ด๋ฏธ์ง€]({image_url})"
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)
@staticmethod
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] ๋ชจ๋ธ์ด ํ…์ŠคํŠธ ์š”์•ฝ์„ ๋งŒ๋“ค๋ฉด์„œ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ๊ฒฐ๊ณผ(๋งˆํฌ๋‹ค์šด ![](url))๋ฅผ
# ๋ˆ„๋ฝํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žฆ๋‹ค. ๋„๊ตฌ ๊ฒฐ๊ณผ์— ์ด๋ฏธ์ง€๊ฐ€ ์žˆ๊ณ  ๋ณธ๋ฌธ์— ์•„์ง ์—†์œผ๋ฉด ๊ฐ•์ œ๋กœ ์ฒจ๋ถ€ํ•˜์—ฌ
# ์ฑ„ํŒ…์— ์ด๋ฏธ์ง€๊ฐ€ ํ•ญ์ƒ ํ‘œ์‹œ๋˜๊ฒŒ ํ•œ๋‹ค. (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)
}