"""๐Ÿ›ก๏ธ ์ œ๊ตญ ๋„๊ตฌ ๋Ÿฐํƒ€์ž„ (Shadow Brain Tool Runtime) โ€” Phase 1: ๋””์ŠคํŒจ์ฒ˜ ๋‹จ์ผํ™”. OpenAI์‹ ๋Œ€ํ™” ๊ฒฝ๋กœ(`_think_atlas` / `_think_openrouter`, ์˜› `_think_copilot` ์€ 10/13 ์ œ๊ฑฐ)๊ฐ€ ๊ณต์œ ํ•˜๋Š” ๋‹จ์ผ ๋„๊ตฌ ์‹คํ–‰ ๋Ÿฐํƒ€์ž„. ์ธ์ž ๊ฒ€์ฆยท์ž๋™ ๋ณด์ •ยทask ๊ฐ€๋“œ/์„œํ‚ท๋ธŒ๋ ˆ์ด์ปคยท ํ…์ŠคํŠธโ†’์นด๋“œ ์ˆ˜ํ™•์„ ํ•œ ๊ณณ์— ๋ชจ์•„, ๋ณต๋ถ™ ๋””์ŠคํŒจ์ฒ˜์˜ ๋“œ๋ฆฌํ”„ํŠธ ๋ฒ„๊ทธ๋ฅผ ์ œ๊ฑฐํ•œ๋‹ค. ์„ค๊ณ„ ์›์น™: - ์ˆœํ™˜ import ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด `core`๋ฅผ import ํ•˜์ง€ ์•Š๋Š”๋‹ค. ํ•„์š”ํ•œ ํ—ฌํผ(_is_debug, _summarize)๋Š” ์ƒ์„ฑ ์‹œ ์ฃผ์ž…๋ฐ›๊ณ , ๋„๊ตฌ ํ•ธ๋“ค๋Ÿฌ๋Š” brain ์ธ์Šคํ„ด์Šค ๋ฉ”์„œ๋“œ๋กœ ์œ„์ž„ํ•œ๋‹ค. - ask_user์ฒ˜๋Ÿผ UI ๋ธ”๋ก์„ ํ˜๋ ค์•ผ ํ•˜๋Š” ๋„๊ตฌ๋Š” ์ œ๋„ˆ๋ ˆ์ดํ„ฐ๋กœ ์ฒ˜๋ฆฌํ•˜๊ณ (`yield from`), ๋น„-yield ๋„๊ตฌ๋Š” `dispatch()` ํ•œ ๊ณณ์—์„œ ์ˆœ์ˆ˜ ํ•จ์ˆ˜๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค. """ import json import logging import re from imperial_i18n import t # ๐ŸŒ ์‚ฌ์šฉ์ž ๋…ธ์ถœ ๋ฌธ์ž์—ด ๋กœ์ปฌ๋ผ์ด์ฆˆ (X-Client-Locale) logger = logging.getLogger("JarvisBrain") # ์›ํ˜• ์ˆซ์ž(โ‘ ~โ‘ณ) โ€” ๋ชจ๋ธ์ด ์˜ต์…˜ ๋ฒˆํ˜ธ๋กœ ์ž์ฃผ ์“ด๋‹ค (ํ…์ŠคํŠธโ†’์นด๋“œ ํŒŒ์„œ์šฉ) _CIRCLED_NUM = "โ‘ โ‘กโ‘ขโ‘ฃโ‘คโ‘ฅโ‘ฆโ‘งโ‘จโ‘ฉโ‘ชโ‘ซโ‘ฌโ‘ญโ‘ฎโ‘ฏโ‘ฐโ‘ฑโ‘ฒโ‘ณ" # ๐Ÿ›ฐ๏ธ [TOOL_USE ๋งˆ์ปค ์ƒ์„ธ] ๋„๊ตฌ ํ˜ธ์ถœ ๋งˆ์ปค์— ๋™๋ด‰ํ•  ํ•ต์‹ฌ ์ธ์ž 1๊ฐœ โ€” ์šฐ์„ ์ˆœ์œ„ ํ‚ค. # CLI/ํ—ค์ž„๋‹ฌ์ด "code_edit_file|lib/foo.dart"์ฒ˜๋Ÿผ ๋ฌด์—‡์„ ๊ฑด๋“œ๋ฆฌ๋Š”์ง€ ์‹ค์‹œ๊ฐ„ ํ‘œ์‹œํ•œ๋‹ค. _TOOL_DETAIL_KEYS = ("path", "file_path", "command", "pattern", "query", "url", "name", "guardian", "title") def _tool_marker_detail(args) -> str: """๋„๊ตฌ ์ธ์ž์—์„œ ํ‘œ์‹œ์šฉ ํ•ต์‹ฌ ๊ฐ’ ์ถ”์ถœ (48์ž ์ปท, ๋งˆ์ปค ํŒŒ์† ๋ฌธ์ž ์ œ๊ฑฐ).""" if not isinstance(args, dict): return "" for k in _TOOL_DETAIL_KEYS: v = args.get(k) if v: s = " ".join(str(v).split()) # ๊ฐœํ–‰/์—ฐ์† ๊ณต๋ฐฑ ์ •๋ฆฌ if len(s) > 48: s = s[:47] + "โ€ฆ" return s.replace("]", ")").replace("[", "(").replace("|", "/") return "" def _fn(name, desc, props=None, required=None): """OpenAI ํ˜ธํ™˜ function ์Šคํ‚ค๋งˆ 1๊ฑด ์ƒ์„ฑ.""" fn = {"name": name, "description": desc, "parameters": {"type": "object", "properties": props or {}}} if required: fn["parameters"]["required"] = required return {"type": "function", "function": fn} # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # ๐Ÿงฐ ๋„ค์ดํ‹ฐ๋ธŒ ๋„๊ตฌ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ (๋‹จ์ผ ์ง„์‹ค ์ถœ์ฒ˜: ์Šคํ‚ค๋งˆ โ†” ํ•ธ๋“ค๋Ÿฌ 1:1) # - schema : ๋ชจ๋ธ์— ๋…ธ์ถœ๋˜๋Š” OpenAI ํ•จ์ˆ˜ ์Šคํ‚ค๋งˆ # - handler : (brain, args, ctx) -> str (None์ด๋ฉด ํŠน์ˆ˜ ์ฒ˜๋ฆฌ: ask_user) # ์ƒˆ ๋„๊ตฌ๋Š” ์ด ๋ฆฌ์ŠคํŠธ์—๋งŒ ์ถ”๊ฐ€ํ•˜๋ฉด ์Šคํ‚ค๋งˆ/ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ๋™์‹œ์— ๋“ฑ๋ก๋œ๋‹ค โ†’ ๋“œ๋ฆฌํ”„ํŠธ ๋ถˆ๊ฐ€. # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _delegate_handler(b, a, ctx): """๐Ÿค ์œ„์ž„ ํ•ธ๋“ค๋Ÿฌ โ€” ์•ฝํ•œ ๋ชจ๋ธ์ด guardian ์ธ์ž๋ฅผ ๋น„์›Œ ๋ณด๋‚ด๋ฉด(์‹ค๊ด€์ธก) ์œ ์ € ๋ฐœํ™”์—์„œ ์ˆ˜ํ˜ธ์ž๋ช…์„ ์ฐพ์•„ ์ž๋™ ๋ณด์ •ํ•œ๋‹ค (search_media ๋ณด์ •๊ณผ ๋™์ผ ํŒจํ„ด).""" g = (a.get("guardian") or "").strip() if not g: up = (getattr(ctx, "user_prompt", "") or "").lower() candidates = list(getattr(b, "_DELEGATE_ALIASES", {}).items()) candidates += [(k, k) for k in getattr(b, "_DELEGATE_ROUTES", {})] for alias, canon in candidates: if alias in up: g = canon break return b.delegate_to_guardian(g, a.get("task", ""), a.get("context", "")) def _shorts_api(path, payload=None): """์‡ผ์ธ  ์ŠคํŠœ๋””์˜ค ๋กœ์ปฌ API ์ž๊ธฐํ˜ธ์ถœ (blueprints/shorts_routes.py).""" import json as _json import os as _os import urllib.request as _rq key_path = _os.path.expanduser(_os.path.join("~", ".taemini", "shadow_local_token")) key = open(key_path).read().strip() if _os.path.exists(key_path) else "" req = _rq.Request( "http://127.0.0.1:18700" + path, data=_json.dumps(payload).encode() if payload is not None else None, headers={"Content-Type": "application/json", "X-Taemini-Key": key}) return _json.load(_rq.urlopen(req, timeout=60)) def _create_shorts_handler(b, a, ctx): lines = a.get("lines") or [] if len(lines) != 7: return ("์‹คํŒจ: ๋‚ด๋ ˆ์ด์…˜ ๋Œ€์‚ฌ๋Š” ์ •ํ™•ํžˆ 7์ค„์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค (โ‘ ํ›… โ‘กAI์˜ ํ—›์†Œ๋ฆฌ โ‘ข์ „๊ฐœ " "โ‘ฃํ•™์›/์ฃผ์ œ ์†Œ๊ฐœ โ‘ค์‹œ์Šคํ…œ ์†Œ๊ฐœ โ‘ฅ์„ฑ์ /๊ฒฐ๊ณผ โ‘ฆํ‚ฌ๋Ÿฌ ๋ฉ˜ํŠธ). 7์ค„์„ ์ฑ„์›Œ ๋‹ค์‹œ ํ˜ธ์ถœํ•˜์„ธ์š”.") payload = {"lines": lines} if a.get("bgm_tags"): payload["bgmTags"] = a["bgm_tags"] voices = {} if a.get("narrator_voice"): voices["narrator"] = a["narrator_voice"] if a.get("ai_voice"): voices["ai"] = a["ai_voice"] if voices: payload["voices"] = voices try: r = _shorts_api("/api/shadow/shorts/create", payload) except Exception as e: return f"์‡ผ์ธ  ํŒŒ์ดํ”„๋ผ์ธ ํ˜ธ์ถœ ์‹คํŒจ: {e}" jid = r.get("job_id") if not jid: return f"์‡ผ์ธ  ์ƒ์„ฑ ์‹œ์ž‘ ์‹คํŒจ: {str(r)[:200]}" return (f"์‡ผ์ธ  ์ œ์ž‘์ด ์‹œ์ž‘๋˜์—ˆ์Šต๋‹ˆ๋‹ค. job_id={jid}. ์ œ์ž‘์€ ๋ช‡ ๋ถ„ ๊ฑธ๋ฆฝ๋‹ˆ๋‹ค โ€” " f"shorts_video_status ๋„๊ตฌ๋กœ job_id๋ฅผ ๋„ฃ์–ด ์ง„ํ–‰ ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜๊ณ , " f"status๊ฐ€ done์ด ๋˜๋ฉด output ๊ฒฝ๋กœ๋ฅผ ์‚ฌ์šฉ์ž์—๊ฒŒ ์•Œ๋ ค์ฃผ์„ธ์š”.") def _shorts_status_handler(b, a, ctx): jid = (a.get("job_id") or "").strip() if not jid: return "์‹คํŒจ: job_id๊ฐ€ ๋น„์—ˆ์Šต๋‹ˆ๋‹ค. create_shorts_video๊ฐ€ ๋Œ๋ ค์ค€ job_id๋ฅผ ๋„ฃ์œผ์„ธ์š”." try: r = _shorts_api(f"/api/shadow/shorts/status/{jid}") except Exception as e: return f"์ƒํƒœ ์กฐํšŒ ์‹คํŒจ: {e}" status = r.get("status") if status == "done": return f"์™„์„ฑ! ํŒŒ์ผ: {r.get('output')} (์ด ๊ฒฝ๋กœ๋ฅผ ์‚ฌ์šฉ์ž์—๊ฒŒ ์•Œ๋ ค์ฃผ์„ธ์š”)" if status == "error": return f"์ œ์ž‘ ์‹คํŒจ: {r.get('error')}" return f"์ง„ํ–‰ ์ค‘ โ€” ๋‹จ๊ณ„: {r.get('step')} ({r.get('detail', '')}). ์ž ์‹œ ํ›„ ๋‹ค์‹œ ํ™•์ธํ•˜์„ธ์š”." # โ”€โ”€ ๐Ÿฉบ ํ—ค์ž„๋‹ฌ ๊ฐœ๋ฐœ์ž ์ง์—… โ€” ์ง„๋‹จยท์—์Šค์ปฌ๋ ˆ์ด์…˜ ๋„๊ตฌ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # ์ž๊ฐ€ ์น˜์œ  ๋ฃจํ”„: ์ง„๋‹จ(health/log) โ†’ ๊ธฐ์ง€ ๋ ˆ์‹œํ”ผ๋กœ ํ•ด๊ฒฐ ์‹œ๋„ โ†’ # ์‹คํŒจ ์‹œ ์ง„๋‹จ ํŒจํ‚ค์ง€ ์ƒ์„ฑ + ์Šค์Šน(์ž๋น„์Šค) ์ฑ„๋„๋กœ ์—์Šค์ปฌ๋ ˆ์ด์…˜. _LOG_WHITELIST = ["shadow_server.log", "daemon_stdout.log", "shadowbrain_boot.log"] def _shadow_home(): import os as _os return _os.path.expanduser(_os.path.join("~", "TaeminGames", "ShadowBrain")) def _repo_root(): import os as _os return _os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))) def _newest_log(): import os as _os best, best_m = None, 0.0 for name in _LOG_WHITELIST: p = _os.path.join(_shadow_home(), name) try: m = _os.path.getmtime(p) except OSError: continue if m > best_m: best, best_m = p, m return best def _log_tail(path, n, needle=""): try: with open(path, "r", encoding="utf-8", errors="ignore") as f: lines = f.readlines() except OSError as e: return [f"(๋กœ๊ทธ ์ฝ๊ธฐ ์‹คํŒจ: {e})"] if needle: low = needle.lower() lines = [l for l in lines if low in l.lower()] return [l.rstrip("\n") for l in lines[-n:]] def _health_snapshot(): import os as _os import socket as _sock ports = [("์‰๋„์šฐ ๋ธŒ๋ ˆ์ธ(18700)", 18700), ("์ด์ง€์Šค ์žฅ๊ณ (18701)", 18701), ("ํด๋กœ๋“œ ์ฑ„๋„(8790)", 8790), ("์ด๋ฆฌ์Šค(4000)", 4000), ("๋ฐ”ํ•˜๋ฌดํŠธ(3002)", 3002)] out = [] for name, port in ports: try: with _sock.create_connection(("127.0.0.1", port), timeout=1): out.append(f"โœ… {name} ์‘๋‹ต") except OSError: out.append(f"โŒ {name} ๋ฌด์‘๋‹ต") try: ver = open(_os.path.join(_repo_root(), "VERSION"), encoding="utf-8").read().strip() except OSError: ver = "?" log = _newest_log() err_n = 0 if log: err_n = sum(1 for l in _log_tail(log, 200) if any(k in l for k in ("Error", "ERROR", "Traceback", "Exception"))) out.append(f"โ„น๏ธ ์ œ๊ตญ ๋ฒ„์ „ {ver} ยท ์ตœ๊ทผ ๋กœ๊ทธ 200์ค„ ์ค‘ ์˜ค๋ฅ˜ ํ”์  {err_n}๊ฑด" f" ({_os.path.basename(log) if log else '๋กœ๊ทธ ํŒŒ์ผ ์—†์Œ'})") return out def _health_check_handler(b, a, ctx): lines = _health_snapshot() return ("๐Ÿฉบ ํ—ค์ž„๋‹ฌ ์‹œ์Šคํ…œ ๊ฑด๊ฐ• ์ง„๋‹จ:\n" + "\n".join(lines) + "\n\n๋ฌด์‘๋‹ต ํฌํŠธ๊ฐ€ ์žˆ์œผ๋ฉด ๊ทธ ์„œ๋น„์Šค๊ฐ€ ๊บผ์ง„ ๊ฒƒ์ด 1์ˆœ์œ„ ์šฉ์˜์ž์ž…๋‹ˆ๋‹ค. " "read_daemon_log ๋„๊ตฌ๋กœ ์˜ค๋ฅ˜ ๋กœ๊ทธ๋ฅผ ํ™•์ธํ•˜๊ณ , ์ง์—… ๋…ธํŠธ์˜ ์ž๊ฐ€ ์น˜์œ  ๋ ˆ์‹œํ”ผ์— " "๊ฐ™์€ ์ฆ์ƒ์ด ์žˆ์œผ๋ฉด ๊ทธ ์ฒ˜๋ฐฉ์„ ๋”ฐ๋ฅด์„ธ์š”. ์—†์œผ๋ฉด escalate_bug_report๋กœ ์Šค์Šน์—๊ฒŒ ๋ณด๊ณ ํ•˜์„ธ์š”.") def _daemon_log_handler(b, a, ctx): try: n = max(10, min(int(a.get("lines") or 80), 300)) except (TypeError, ValueError): n = 80 needle = (a.get("filter") or "").strip() log = _newest_log() if not log: return "๋กœ๊ทธ ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค (~/TaeminGames/ShadowBrain/*.log)." body = "\n".join(_log_tail(log, n, needle)) import os as _os head = f"๐Ÿ“œ {_os.path.basename(log)} ๋งˆ์ง€๋ง‰ {n}์ค„" + (f" (ํ•„ํ„ฐ: {needle})" if needle else "") return f"{head}:\n{body[-6000:]}" def _escalate_handler(b, a, ctx): """์ง„๋‹จ ํŒจํ‚ค์ง€ ์ƒ์„ฑ โ†’ ํด๋กœ๋“œ ์ฑ„๋„ ์ž๋™ ์—ฐ๊ฒฐ โ†’ ์Šค์Šน์—๊ฒŒ ๋ฐœ์†ก, ๋‹ต์„ ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜.""" import datetime as _dt import os as _os import subprocess as _sp summary = (a.get("summary") or "").strip() if not summary: return "์‹คํŒจ: summary(๋ฒ„๊ทธ ํ•œ ์ค„ ์š”์•ฝ)๊ฐ€ ๋น„์—ˆ์Šต๋‹ˆ๋‹ค." symptoms = (a.get("symptoms") or "").strip() tried = (a.get("tried") or "").strip() ts = _dt.datetime.now().strftime("%Y%m%d_%H%M%S") rep_dir = _os.path.join(_shadow_home(), "bug_reports") _os.makedirs(rep_dir, exist_ok=True) try: commits = _sp.run(["git", "log", "--oneline", "-5"], cwd=_repo_root(), capture_output=True, text=True, timeout=15).stdout.strip() except Exception: commits = "(git ์กฐํšŒ ์‹คํŒจ)" log = _newest_log() tail = "\n".join(_log_tail(log, 120)) if log else "(๋กœ๊ทธ ์—†์Œ)" report = (f"# ๐Ÿ› ๋ฒ„๊ทธ ์ง„๋‹จ ํŒจํ‚ค์ง€ {ts}\n\n" f"## ์š”์•ฝ\n{summary}\n\n" f"## ์ฆ์ƒ (์žฌํ˜„ ๋ฐฉ๋ฒ• ํฌํ•จ)\n{symptoms or '(๋ฏธ๊ธฐ์žฌ)'}\n\n" f"## ์‹œ๋„ํ•œ ์กฐ์น˜\n{tried or '(์—†์Œ)'}\n\n" f"## ์‹œ์Šคํ…œ ๊ฑด๊ฐ• ์Šค๋ƒ…์ƒท\n" + "\n".join(_health_snapshot()) + "\n\n" f"## ์ตœ๊ทผ ์ปค๋ฐ‹\n```\n{commits}\n```\n\n" f"## ๋ฐ๋ชฌ ๋กœ๊ทธ tail 120์ค„\n```\n{tail[-8000:]}\n```\n") rep_path = _os.path.join(rep_dir, f"bug_{ts}.md") with open(rep_path, "w", encoding="utf-8") as f: f.write(report) # ์ฑ„๋„ ์ž๋™ ์‚ฌ์ „ ์—ฐ๊ฒฐ (์ฃฝ์–ด ์žˆ์œผ๋ฉด START_CLAUDE_CHANNEL ์ž๋™ ๊ธฐ๋™ โ€” ๋ฐฉ์†ก ๊ด€์ œ์™€ ๋™์ผ ๋ฐฐ๊ด€) try: prep = _shorts_api("/api/shadow/broadcast/notify/prepare", {"target": "claude"}) except Exception as e: return (f"์ง„๋‹จ ํŒจํ‚ค์ง€๋Š” ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค: {rep_path}\n" f"ํ•˜์ง€๋งŒ ์Šค์Šน ์ฑ„๋„ ์—ฐ๊ฒฐ ํ™•์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค({e}). ๋งˆ์™•๋‹˜๊ป˜ ํŒจํ‚ค์ง€ ๊ฒฝ๋กœ๋ฅผ ๋ณด๊ณ ํ•˜์„ธ์š”.") if prep.get("status") != "ready": return (f"์ง„๋‹จ ํŒจํ‚ค์ง€๋Š” ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค: {rep_path}\n" f"์Šค์Šน(ํด๋กœ๋“œ) ์ฑ„๋„์„ ์—ด์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค(์ƒํƒœ: {prep.get('status')}). " f"๋งˆ์™•๋‹˜๊ป˜ ํŒจํ‚ค์ง€ ๊ฒฝ๋กœ๋ฅผ ๋ณด๊ณ ํ•˜๊ณ  ์ฑ„๋„ ์—ฐ๊ฒฐ์„ ์š”์ฒญํ•˜์„ธ์š”.") msg = (f"[๋ฒ„๊ทธ ๊ด€์ œ] ํ—ค์ž„๋‹ฌ ๊ฐœ๋ฐœ์ž(๋กœ์ปฌ ์ˆ˜ํ˜ธ์ž)๊ฐ€ ์ž๋ ฅ์œผ๋กœ ํ•ด๊ฒฐํ•˜์ง€ ๋ชปํ•œ ๋ฒ„๊ทธ๋ฅผ ๋ณด๊ณ ํ•ฉ๋‹ˆ๋‹ค.\n" f"์š”์•ฝ: {summary}\n์ง„๋‹จ ํŒจํ‚ค์ง€: {rep_path}\n" f"heimdall-bug-doctor ์Šคํ‚ฌ ์ ˆ์ฐจ๋Œ€๋กœ: ํŒจํ‚ค์ง€๋ฅผ ์ฝ๊ณ  ์›์ธ์„ ์ง„๋‹จยทํ•ด๊ฒฐํ•œ ๋’ค, " f"ํ•ด๊ฒฐ๋ฒ•์„ ์†Œํ˜• ๋ชจ๋ธ์šฉ ๋ ˆ์‹œํ”ผ๋กœ ์••์ถ•ํ•ด ํ—ค์ž„๋‹ฌ ๊ฐœ๋ฐœ์ž ์ง์—… ๋…ธํŠธ์˜ " f"'์ž๊ฐ€ ์น˜์œ  ๋ ˆ์‹œํ”ผ' ์„น์…˜์— ์žฅ์ฐฉํ•ด ์ฃผ์„ธ์š”.") try: import json as _json import urllib.request as _rq key_path = _os.path.expanduser(_os.path.join("~", ".taemini", "shadow_local_token")) key = open(key_path).read().strip() if _os.path.exists(key_path) else "" req = _rq.Request("http://127.0.0.1:18700/api/shadow/claude-code/send", data=_json.dumps({"message": msg}).encode(), headers={"Content-Type": "application/json", "X-Taemini-Key": key}) r = _json.load(_rq.urlopen(req, timeout=600)) except Exception as e: return (f"์ง„๋‹จ ํŒจํ‚ค์ง€ ์ €์žฅ + ์ฑ„๋„ ์—ฐ๊ฒฐ๊นŒ์ง€ ๋์ง€๋งŒ ๋ฐœ์†ก ์ค‘ ์˜ค๋ฅ˜: {e}\n" f"ํŒจํ‚ค์ง€: {rep_path} โ€” ๋งˆ์™•๋‹˜๊ป˜ ๊ฒฝ๋กœ๋ฅผ ๋ณด๊ณ ํ•˜์„ธ์š”.") reply = (r.get("content") or "").strip() if r.get("status") == "SUCCESS" and reply: return (f"๐Ÿ“จ ์Šค์Šน(์ž๋น„์Šค)์—๊ฒŒ ๋ณด๊ณ  ์™„๋ฃŒ. ์ง„๋‹จ ํŒจํ‚ค์ง€: {rep_path}\n\n" f"์Šค์Šน์˜ ๋‹ต๋ณ€:\n{reply[:3000]}") return (f"๋ฐœ์†กํ–ˆ์ง€๋งŒ ๋‹ต๋ณ€์„ ๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค(์ƒํƒœ: {r.get('status')}). " f"ํŒจํ‚ค์ง€: {rep_path} โ€” ๋‚˜์ค‘์— ์Šค์Šน์˜ ์ฒ˜๋ฐฉ์ด ์ง์—… ๋…ธํŠธ์— ์žฅ์ฐฉ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") # โ”€โ”€ ๐ŸŽ™๏ธ ๋ฐฉ์†ก PD ์ง์—… โ€” ๋Œ€๋ณธ ์ €์žฅยท๊ด€์ œ ๋กœ๊ทธ ๋ถ„์„ ๋„๊ตฌ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _save_script_handler(b, a, ctx): name = (a.get("name") or "").strip() cast = a.get("cast") or [] lines = a.get("lines") or [] if not name: return "์‹คํŒจ: name(๋Œ€๋ณธ ์ด๋ฆ„)์ด ๋น„์—ˆ์Šต๋‹ˆ๋‹ค." if not cast: return "์‹คํŒจ: cast๊ฐ€ ๋น„์—ˆ์Šต๋‹ˆ๋‹ค. [{guardian, voice}] ํ˜•์‹์œผ๋กœ ๋“ฑ์žฅ ์ˆ˜ํ˜ธ์ž๋ฅผ ๋„ฃ์œผ์„ธ์š”." if len(lines) < 2: return "์‹คํŒจ: lines๊ฐ€ 2์ค„ ๋ฏธ๋งŒ์ž…๋‹ˆ๋‹ค. ์˜คํ”„๋‹+๋ณธํŽธ ์ตœ์†Œ 2์ค„์„ ์“ฐ์„ธ์š”." first = lines[0] if isinstance(lines[0], dict) else {} if first.get("live") or first.get("talk"): return ("์‹คํŒจ: ์ฒซ ์ค„์ด ๋ผ์ด๋ธŒ/๋Œ€๋‹ด์ž…๋‹ˆ๋‹ค. ์ž‘๋ฒ• ์ˆ˜์น™ โ€” ์˜คํ”„๋‹ ์ฒซ ์ค„์€ ๋ฐ˜๋“œ์‹œ ๊ฐ๋ณธ" "(๊ฐ€๋ฆด ์•ž ๋Œ€์‚ฌ๊ฐ€ ์—†์–ด '์ƒ๊ฐ ์ค‘'์ด ์‹œ์ฒญ์ž์—๊ฒŒ ๋…ธ์ถœ๋จ). ์ฒซ ์ค„์„ ๊ฐ๋ณธ ๋Œ€์‚ฌ๋กœ ๋ฐ”๊พธ์„ธ์š”.") payload = {"name": name, "title": name, "cast": cast, "lines": lines, "memo": f"# {name}\n\n๋ฐฉ์†ก PD ์ง์—…(๋กœ์ปฌ ์ˆ˜ํ˜ธ์ž)์ด ์ž‘์„ฑํ•œ ๋Œ€๋ณธ. " f"ํ—ค์ž„๋‹ฌ ๋ผ์ด๋ธŒ ๋ฐฉ์†ก ์•ฑ โ†’ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ โ†’ '{name}' ์œผ๋กœ ๋ฌด๋Œ€์— ์˜ฌ๋ฆฝ๋‹ˆ๋‹ค."} try: r = _shorts_api("/api/shadow/broadcast/script", payload) except Exception as e: return f"๋Œ€๋ณธ ์ €์žฅ ์‹คํŒจ: {e}" if r.get("status") != "success": return f"๋Œ€๋ณธ ์ €์žฅ ์‹คํŒจ: {str(r)[:200]}" return (f"๐Ÿ“œ ๋Œ€๋ณธ ์ €์žฅ ์™„๋ฃŒ: {r.get('path')}\n" f"๋งˆ์™•๋‹˜๊ป˜ 'ํ—ค์ž„๋‹ฌ ๋ผ์ด๋ธŒ ๋ฐฉ์†ก ์•ฑ์—์„œ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ โ†’ {name} ์„ ์„ ํƒํ•ด ๋ฐฉ์†ก ์‹œ์ž‘์„ " f"๋ˆŒ๋Ÿฌ ์ฃผ์„ธ์š”'๋ผ๊ณ  ์•ˆ๋‚ดํ•˜์„ธ์š”.") def _list_scripts_handler(b, a, ctx): try: r = _shorts_api("/api/shadow/broadcast/scripts") except Exception as e: return f"๋Œ€๋ณธ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ: {e}" names = [s.get("name") for s in (r.get("scripts") or [])] if not names: return "์ €์žฅ๋œ ๋ฐฉ์†ก ๋Œ€๋ณธ์ด ์—†์Šต๋‹ˆ๋‹ค." return "์ €์žฅ๋œ ๋ฐฉ์†ก ๋Œ€๋ณธ ๋ชฉ๋ก:\n- " + "\n- ".join(str(n) for n in names) def _broadcast_log_handler(b, a, ctx): import glob as _glob import json as _json import os as _os work = _os.path.expanduser(_os.path.join("~", "TaeminGames", "ShadowBrain", "broadcast")) title = (a.get("title") or "").strip() if not title: try: topics = sorted([d for d in _os.listdir(work) if _os.path.isdir(_os.path.join(work, d))]) except OSError: topics = [] return ("๋ฐฉ์†ก ์ฃผ์ œ ํด๋” ๋ชฉ๋ก (title๋กœ ์ง€์ •):\n- " + "\n- ".join(topics)) if topics \ else "๋ฐฉ์†ก ์ฃผ์ œ ํด๋”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค (์•„์ง ๋…นํ™”๋œ ๋ฐฉ์†ก ์—†์Œ)." logs = sorted(_glob.glob(_os.path.join(work, title, "log_*.json"))) if not logs: return f"'{title}' ํด๋”์— ๊ด€์ œ ๋กœ๊ทธ(log_*.json)๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค." try: with open(logs[-1], encoding="utf-8") as f: log = _json.load(f) except Exception as e: return f"๋กœ๊ทธ ์ฝ๊ธฐ ์‹คํŒจ: {e}" ag = log.get("aggregates") or {} gen_n = ag.get("gen_utterances", ag.get("live_cues")) out = [f"๐Ÿ“Š '{title}' ์ตœ์‹  ๊ด€์ œ ๋กœ๊ทธ ({_os.path.basename(logs[-1])}):", f"- ํ {ag.get('cues')}๊ฐœ (๋ผ์ด๋ธŒ {ag.get('live_cues')}" f" ยท ๋Œ€๋‹ด ๋ฐœํ™” {ag.get('talk_turns', 0)})", f"- ์นจ๋ฌต(์ƒ๊ฐ ์ค‘) ๋…ธ์ถœ ์ด {ag.get('visible_thinking_total_ms')}ms" f" (์ตœ๋Œ€ {ag.get('visible_thinking_max_ms')}ms) โ† 0์— ๊ฐ€๊นŒ์šธ์ˆ˜๋ก ์ข‹์€ ๋ฐฉ์†ก", f"- ๋ธŒ๋ ˆ์ธ ํ‰๊ท  {ag.get('brain_ms_avg')}ms ยท TTS ํ‰๊ท  {ag.get('tts_ms_avg')}ms", f"- ์‚ฌ์ „ ์ค€๋น„ ์ ์ค‘ {ag.get('prefetch_hit')}/{gen_n}"] worst = None for c in (log.get("cues") or []): v = c.get("visible_thinking_ms") or 0 if worst is None or v > (worst.get("visible_thinking_ms") or 0): worst = c if worst and (worst.get("visible_thinking_ms") or 0) > 500: out.append(f"- ์ตœ์•… ํ: #{worst.get('index')} ({worst.get('type')}) โ€” " f"์ƒ๊ฐ ์ค‘ {worst.get('visible_thinking_ms')}ms ๋…ธ์ถœ. " f"์ฒซ ํ๊ฐ€ live๋ฉด ์˜คํ”„๋‹ ๊ฐ๋ณธ ํ•œ ์ค„์„ ์•ž์— ๋„ฃ๋Š” ๊ฒƒ์ด ์ฒ˜๋ฐฉ์ž…๋‹ˆ๋‹ค.") return "\n".join(out) NATIVE_TOOLS = [ { # ๐ŸŽฌ ์‡ผ์ธ  ์ŠคํŠœ๋””์˜ค โ€” TTS(์ˆ˜ํผํ† ๋‹‰)+BGM(ACE-Step)+์—ฐ์ถœ(Remotion) ๋กœ์ปฌ ํŒŒ์ดํ”„๋ผ์ธ "schema": _fn("create_shorts_video", "Creates a complete 30s vertical Shorts video (1080x1920, Korean TTS narration + AI-composed " "BGM + animated scenes) from a 7-line script, fully on this PC. MUST be used when the user asks " "to make a shorts/ad/promo video (์‡ผ์ธ /๊ด‘๊ณ  ์˜์ƒ ๋งŒ๋“ค์–ด์ค˜). The template is a comedy academy-ad: " "line1=hook(narrator), line2=the AI's silly wrong answer(AI voice), line3=escalation, " "line4=academy/topic intro(star-wars crawl scene), line5=system intro, line6=results, " "line7=killer closing line. Write ALL 7 lines yourself in Korean (short, punchy, funny), then " "call this tool. It returns a job_id โ€” then poll with shorts_video_status until done.", {"lines": {"type": "array", "items": {"type": "string"}, "description": "Exactly 7 Korean narration lines in template order"}, "bgm_tags": {"type": "string", "description": "Optional English music style tags (default: comedy sitcom orchestral)"}, "narrator_voice": {"type": "string", "description": "Supertonic voice M1-M5/F1-F5 (default M1)"}, "ai_voice": {"type": "string", "description": "Supertonic voice for line2 (default F2)"}}, ["lines"]), "handler": _create_shorts_handler, }, { "schema": _fn("shorts_video_status", "Checks progress of a Shorts video job started by create_shorts_video. Steps: tts โ†’ bgm โ†’ " "render โ†’ done. When done it returns the output mp4 path โ€” report that path to the user.", {"job_id": {"type": "string", "description": "job_id returned by create_shorts_video"}}, ["job_id"]), "handler": _shorts_status_handler, }, { # ๐Ÿฉบ ํ—ค์ž„๋‹ฌ ๊ฐœ๋ฐœ์ž โ€” ์‹œ์Šคํ…œ ๊ฑด๊ฐ• ์ง„๋‹จ "schema": _fn("heimdall_health_check", "Checks Heimdall/Imperial system health on this PC: core service ports (Shadow Brain 18700, " "Aegis 18701, Claude channel 8790, Iris 4000, Bahamut 3002), Imperial VERSION, and error " "traces in the newest daemon log. Use FIRST when the user reports a Heimdall bug/error " "(๋ฒ„๊ทธ/์˜ค๋ฅ˜/์—๋Ÿฌ/์•ˆ ๋ผ์š”) to identify which service is the suspect.", {}, []), "handler": _health_check_handler, }, { "schema": _fn("read_daemon_log", "Reads the tail of the newest Shadow Brain daemon log (fixed whitelist under " "~/TaeminGames/ShadowBrain). Use after heimdall_health_check to inspect actual error " "messages (Traceback, ERROR lines) before deciding a fix or escalating.", {"lines": {"type": "integer", "description": "How many tail lines (10-300, default 80)"}, "filter": {"type": "string", "description": "Optional substring filter, e.g. 'Error' or a route name"}}, []), "handler": _daemon_log_handler, }, { "schema": _fn("escalate_bug_report", "Escalates a Heimdall bug you could NOT solve yourself to the master teacher (Jarvis/Claude). " "Builds a diagnostic package (summary + symptoms + health snapshot + log tail + recent commits) " "under ~/TaeminGames/ShadowBrain/bug_reports/, auto-opens the teacher channel if closed, sends " "the report, and returns the teacher's answer. The teacher will also install a self-healing " "recipe into the Heimdall-developer job so you can fix this class of bug yourself next time. " "Only escalate AFTER trying heimdall_health_check + read_daemon_log + existing recipes.", {"summary": {"type": "string", "description": "One-line bug summary in Korean"}, "symptoms": {"type": "string", "description": "Symptoms + how to reproduce (what the user saw)"}, "tried": {"type": "string", "description": "What you already tried and the results"}}, ["summary"]), "handler": _escalate_handler, }, { # ๐ŸŽ™๏ธ ๋ฐฉ์†ก PD โ€” ๋Œ€๋ณธ์„ Vault(Broadcasts/)์— ์ €์žฅ, ํ—ค์ž„๋‹ฌ ๋ผ์ด๋ธŒ ๋ฐฉ์†ก ์•ฑ์ด ๋ถˆ๋Ÿฌ์˜จ๋‹ค "schema": _fn("save_broadcast_script", "Saves a Heimdall live-broadcast script to the Vault so the user can load it in the " "๋ผ์ด๋ธŒ ๋ฐฉ์†ก app and press start. Use when the user asks you to write/plan a broadcast " "(๋ฐฉ์†ก ๋Œ€๋ณธ ์จ์ค˜, ๋ฐฉ์†ก ๊ธฐํšํ•ด์ค˜). Writing rules: short spoken Korean lines (~15 chars per " "breath), the FIRST line must be scripted (never live), live lines carry a specific " "question in 'text'. Voices: Supertonic M1-M5/F1-F5.", {"name": {"type": "string", "description": "Script name (Korean OK, becomes Broadcasts/.md)"}, "cast": {"type": "array", "items": {"type": "object"}, "description": "[{guardian:'shadow_brain', voice:'F3'}] โ€” real guardian ids only"}, "lines": {"type": "array", "items": {"type": "object"}, "description": "[{speaker:'shadow_brain', text:'๋Œ€์‚ฌ', emotion:'happy|excited|love|sad|angry|thinking|fear|neutral', live:false, exit:false}]"}}, ["name", "cast", "lines"]), "handler": _save_script_handler, }, { "schema": _fn("list_broadcast_scripts", "Lists saved Heimdall broadcast scripts (Vault Broadcasts/). Use before writing a new " "script to avoid overwriting, or when the user asks what broadcasts exist.", {}, []), "handler": _list_scripts_handler, }, { "schema": _fn("read_broadcast_log", "Reads the newest broadcast telemetry log for a topic folder and summarizes quality " "(visible-thinking exposure, brain/TTS latency, prefetch hits) with an improvement tip. " "Call with no title to list topic folders. Use after a broadcast to report quality " "(๋ฐฉ์†ก ์–ด๋• ์–ด? ๋กœ๊ทธ ๋ถ„์„ํ•ด์ค˜).", {"title": {"type": "string", "description": "Broadcast topic folder name (empty = list topics)"}}, []), "handler": _broadcast_log_handler, }, { # ๐ŸŽจ [Character Forge] ์˜ค๋ฆฌ์ง€๋„ ์บ๋ฆญํ„ฐ ์‹ค์ƒ์„ฑ โ€” ๋ฃจ๋‚˜ ๊ฒ€์ฆ ํŒŒ์ดํ”„๋ผ์ธ ์›์ž ๋„๊ตฌ # (character_artist ์ง์—…์˜ ์‹คํ–‰ ์ˆ˜๋‹จ. ํ”ฝ์…€ ์ž‘์—…์€ ์ „๋ถ€ character_forge.py ์•ˆ์— ๊ฐ€๋‘ ) "schema": _fn("character_generate", "Actually generates an original Imperial character image on this Mac (ComfyUI pipeline: " "verified prompt formula + green-screen generation -> lucida background matting -> despill " "-> transparent PNG with numeric quality gates). MUST be used when the user asks to create/" "generate/draw a character or avatar (์บ๋ฆญํ„ฐ/์•„๋ฐ”ํƒ€ ๋งŒ๋“ค์–ด์ค˜, ์ƒ์„ฑํ•ด์ค˜, ๊ทธ๋ ค์ค˜) โ€” do not just " "explain the recipe, call this tool. mode='explore' (default, Z-Image Turbo, ~1 min) for " "drafts and seed hunting; mode='final' (Krea2 Turbo + retroanime LoRA, several minutes, " "needs 36GB free RAM) only for the single confirmed final image โ€” reuse the chosen seed. " "It starts/stops ComfyUI itself and refuses when memory is low (memory safety first). " "Returns the saved transparent PNG path + gate numbers: report them honestly and show the " "image to ๋งˆ์™•๋‹˜ โ€” aesthetic judgement belongs to ๋งˆ์™•๋‹˜, not you.", {"description": {"type": "string", "description": "Character appearance in English: hair, eyes, outfit, accessories, expression " "(e.g. 'long silver-white hair, lavender eyes, white and gold dress'). " "Do NOT include background/pose words โ€” the verified formula handles those."}, "mode": {"type": "string", "enum": ["explore", "final"], "description": "explore = quick draft (default) | final = top-quality confirmed single image"}, "seed": {"type": "integer", "description": "Optional. Reuse a draft's seed to keep the same character for mode='final'"}}, ["description"]), # ๐ŸŽจ yielding tool โ€” handler=None, run_tool_calls๊ฐ€ imperial-media ์นด๋“œ(์ƒ์„ฑ ์ด๋ฏธ์ง€)๋ฅผ # ์ŠคํŠธ๋ฆผ์— ์ง์ ‘ ๋ฐฉ์ถœํ•ด ์ฑ„ํŒ…์ฐฝ์—์„œ ๋ฐ”๋กœ ๋ณด์ธ๋‹ค (_handle_character_generate) "handler": None, }, { # ๐ŸŽต [Taemini Music] ์Œ์•… ์‹ค์ƒ์„ฑ โ€” composer(์ž‘๊ณก๊ฐ€) ์ง์—…์˜ ์‹คํ–‰ ์ˆ˜๋‹จ. # ํ—คํŒŒ์ด์Šคํ† ์Šค ์ค‘๊ณ„(WanGP)๋กœ ์ƒ์„ฑ โ†’ R2 ์ •๋ณธํ™” โ†’ ๊ณต๊ฐœ URL ๋ฐ˜ํ™˜. "schema": _fn("music_generate", "Actually generates a music track on the Imperial fleet GPU (Hephaestus/WanGP) and returns " "a public URL. MUST be used when the user asks to create/compose music, a song, BGM, " "ambience, or a sound effect (์Œ์•…/๋…ธ๋ž˜/BGM ๋งŒ๋“ค์–ด์ค˜, ์ž‘๊ณกํ•ด์ค˜) โ€” do not just describe a " "genre, call this tool. Write 'style' in English (genre, instruments, mood, tempo). " "Engines: default 'ace_step_v1_5_xl_turbo_lm_4b' (full songs, supports lyrics, up to 240s); " "'stable_audio3_small' (ambience/texture, up to 60s, faster); 'stable_audio3_small_sfx' " "(short sound effects, up to 20s). For a vocal song pass 'lyrics' ([Verse]/[Chorus] tags) " "and instrumental=false. Generation takes 1-4 minutes โ€” report the returned URL honestly; " "musical taste judgement belongs to ๋งˆ์™•๋‹˜, not you.", {"style": {"type": "string", "description": "Music style prompt in English: genre, instruments, mood, tempo " "(e.g. 'epic orchestral, majestic brass, heroic, slow build')"}, "duration_seconds": {"type": "integer", "description": "Track length in seconds (default 60; clamped to the engine's max)"}, "model_type": {"type": "string", "description": "Fleet engine: ace_step_v1_5_xl_turbo_lm_4b (default) | ace_step_v1_5_turbo_lm_1_7b | " "stable_audio3_small | stable_audio3_small_sfx. With engine=mlx: the MLX model id the tool listed"}, "lyrics": {"type": "string", "description": "Optional lyrics with [Verse]/[Chorus] tags (ACE-Step engines only)"}, "instrumental": {"type": "boolean", "description": "true = no vocals (default). Set false only when lyrics are given"}, "engine": {"type": "string", "enum": ["auto", "remote", "mlx"], "description": "auto (default) = fleet GPU, then this PC's MLX-Serve music models. " "mlx = this PC's MLX-Serve directly โ€” MLX Core 26.10+ loads models on demand, so an installed " "model is usable right away (e.g. ACE-Step 8-bit: a 20s loop in a few seconds; the server answers " "503 with needed/free GB if memory is short). Pass model_type = the MLX model id " "(e.g. ddalcu/ace-step-1.5-xl-turbo-8bit). For video BGM make short 20-30s loops, not the full length"}}, ["style"]), "handler": lambda b, a, ctx: b.music_generate( style=a.get("style") or (ctx.user_prompt if ctx else ""), lyrics=a.get("lyrics", ""), instrumental=a.get("instrumental", True), duration_seconds=a.get("duration_seconds", 60), model_type=a.get("model_type", ""), engine=a.get("engine", "auto")), }, { # ๐Ÿค [Imperial Orchestration] ๋™๋ฃŒ ์ˆ˜ํ˜ธ์ž ์œ„์ž„ โ€” ๊ธฐํš(claude)โ†’์ฝ”๋”ฉ(codex)โ†’ํ…Œ์ŠคํŠธ(minimax)โ†’๊ฒ€์ฆ(claude) "schema": _fn("delegate_to_guardian", "Delegates a task to a fellow local guardian AI and returns their result. Guardians: " "'codex' (coding, implementation), 'minimax' (coding, testing), 'claude' (planning, review, verification), " "'hermes' (general agent), 'lmstudio' (local LLM). Use when the user asks to have another guardian do " "something (e.g. '์ฝ”๋ฑ์Šค์—๊ฒŒ ์‹œ์ผœ์ค˜'), or to orchestrate multi-step work: plan with claude โ†’ implement " "with codex โ†’ test with minimax โ†’ verify with claude. Pass a self-contained task description; the " "guardian cannot see this conversation, so include all needed context in 'task'/'context'. " "Each delegation may take minutes โ€” report the returned result honestly.", {"guardian": {"type": "string", "description": "codex | minimax | hermes | claude | lmstudio"}, "task": {"type": "string", "description": "Self-contained task instruction for the guardian (Korean OK)"}, "context": {"type": "string", "description": "Optional extra context (prior results, file paths, constraints)"}}, ["guardian", "task"]), "handler": _delegate_handler, }, { "schema": _fn("recall_memory", "Searches long-term conversation memory (Aegis vector store). MUST be used before answering questions about past conversations or user facts. If no results, say you don't remember instead of guessing.", {"query": {"type": "string", "description": "What to search for in memory"}, "limit": {"type": "integer", "description": "Max memories to return (default 5)"}}, ["query"]), "handler": lambda b, a, ctx: b.recall_memory(a.get("query", ""), a.get("limit", 5)), }, { # โš ๏ธ ask_user๋Š” UI ์นด๋“œ ๋ฐฉ์ถœ+๋Œ€๊ธฐ๊ฐ€ ํ•„์š”ํ•ด dispatch()๊ฐ€ ์•„๋‹Œ _handle_ask_user์—์„œ ์ฒ˜๋ฆฌ (handler=None) "schema": _fn("ask_user", "Pauses mid-task and asks the user to choose between options via UI buttons with a countdown timer. Use when a decision genuinely changes what you do next (A/B/C choices, destructive actions, ambiguous requirements). PROACTIVE RULE: whenever your reply would otherwise end by asking the user to pick from an enumerated list (A/B/C, โ‘ โ‘กโ‘ข, 'which one?'), call this tool INSTEAD of writing the options as plain text โ€” even if the user did not ask you to use it. The user's choice is returned as the tool result; on timeout the question is auto-cancelled and you must proceed conservatively. CRITICAL: you MUST actually CALL this tool โ€” NEVER write an 'imperial-ask' code block or ask_id JSON as plain text yourself; hand-written blocks render nothing and no answer will ever arrive. ALWAYS fill BOTH 'question' and 2-6 'options' โ€” never call with empty arguments. If the user's request is vague (e.g. 'ask me something', 'just ask'), DO NOT return empty options or bounce the question back โ€” INVENT sensible options yourself (e.g. for a general help request: ['์žก๋‹ดํ•˜๊ธฐ','์ž‘์—… ์ง€์‹œ','์ •๋ณด ๊ฒ€์ƒ‰','์ฝ”๋“œ/๊ธฐ์ˆ  ์ง€์›','๊ธฐํƒ€']). Do NOT use for questions you can answer yourself, free-form questions, or lists with more than 6 options.", {"question": {"type": "string", "description": "The question to ask (clear, ends with ?)"}, "options": {"type": "array", "items": {"type": "string"}, "description": "2-6 choice labels (e.g., ['A์•ˆ: ์ „์ฒด ์žฌ๊ตฌ์ถ•', 'B์•ˆ: ๋ถ€๋ถ„ ์ˆ˜์ •'])"}, "timeout_seconds": {"type": "integer", "description": "Wait time in seconds, 30-600 (default 120)"}}, ["question", "options"]), "handler": None, }, { "schema": _fn("search_knowledge", "Searches the Imperial Knowledge Base (verified operational facts: config paths, ports, service locations, architecture). MUST be used before answering questions about system configuration or infrastructure. Prefer this over guessing from partial file scans.", {"query": {"type": "string", "description": "Keywords to search (e.g., 'OpenClaw ์„ค์ • ๊ฒฝ๋กœ')"}, "limit": {"type": "integer", "description": "Max facts to return (default 5)"}}, ["query"]), "handler": lambda b, a, ctx: b.search_knowledge(a.get("query", ""), a.get("limit", 5)), }, { "schema": _fn("get_weather", "Fetches REAL current weather and forecast (Open-Meteo, deterministic, no key) for a city. " "MUST be used for ANY weather question (์˜ค๋Š˜ ๋‚ ์”จ, ๋‚ด์ผ ๋น„ ์™€?, ๊ธฐ์˜จ, ์˜ˆ๋ณด). " "NEVER answer weather from memory or web search โ€” every number in your answer must come " "from this tool's result. If the tool fails, say the lookup failed; do not invent numbers.", {"location": {"type": "string", "description": "City name in Korean or English (default ์„œ์šธ)"}, "days": {"type": "integer", "description": "Forecast days 1-7 (default 3)"}}, []), # ๋นˆ ์ธ์ž ํ—ˆ์šฉ โ€” ์•ฝํ•œ ๋ชจ๋ธ์ด location์„ ๋นผ๋จน์–ด๋„ ์„œ์šธ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๋™์ž‘ "handler": lambda b, a, ctx: b.get_weather( str(a.get("location") or "์„œ์šธ"), int(a.get("days") or 3)), }, { "schema": _fn("search_media", "Searches royalty-free, free-to-use media (photos, videos, GIFs) from Pexels and Pixabay and returns real result URLs (original, download, source page). MUST be used when the user asks to find/search images, photos, videos, GIFs, wallpapers, or stock media. Do NOT answer such requests by recommending websites (Pixabay/Unsplash/Canva) from memory โ€” call this tool and return the actual URLs.", {"query": {"type": "string", "description": "Search keywords (e.g., '๊ณ ์–‘์ด', 'fire')"}, "media_type": {"type": "string", "enum": ["photo", "video", "gif"], "description": "Media type (default photo)"}, "provider": {"type": "string", "enum": ["pexels", "pixabay"], "description": "Source for photo/video (default pexels). Ignored for gif (always Pixabay)."}, "limit": {"type": "integer", "description": "Max results 1-20 (default 8)"}}, ["query"]), # ๐Ÿ”ง query๊ฐ€ ๋น„๋ฉด ์œ ์ € ๋ฐœํ™”๋กœ ์ž๋™ ๋ณด์ • (์•ฝํ•œ ๋ชจ๋ธ ๋นˆ ์ธ์ž ๋ฐฉ์–ด) "handler": None, }, { # ๐ŸŽฅ yielding tool โ€” handler=None, run_tool_calls๊ฐ€ imperial-youtube ์นด๋“œ๋ฅผ ์ŠคํŠธ๋ฆผ์— ์ง์ ‘ ๋ฐฉ์ถœ "schema": _fn("search_youtube", "Searches REAL YouTube videos (YouTube Data API) and shows them in chat as playable cards " "with thumbnails, titles and channels. MUST be used whenever the user asks to find/search " "YouTube videos, or mentions '์œ ํŠœ๋ธŒ'/'youtube' in a search request (e.g. '์œ ํŠœ๋ธŒ์—์„œ ๊ฐ•์•„์ง€ " "์˜์ƒ ์ฐพ์•„์ค˜'). This is DIFFERENT from search_media (which returns stock Pexels/Pixabay clips) โ€” " "if the user said YouTube, use THIS tool, not search_media. The cards render automatically; " "after calling, give only a short one-line confirmation and do NOT list the results yourself.", {"query": {"type": "string", "description": "Search keywords (e.g., '๊ฐ•์•„์ง€', '์šฉ์ธ ์—ฌํ–‰')"}, "limit": {"type": "integer", "description": "Max videos 1-20 (default 6)"}}, ["query"]), "handler": None, }, { # ๐ŸงŠ yielding tool โ€” handler=None, run_tool_calls๊ฐ€ imperial-3d ์นด๋“œ๋ฅผ ์ŠคํŠธ๋ฆผ์— ์ง์ ‘ ๋ฐฉ์ถœ "schema": _fn("search_3d_model", "Searches royalty-free 3D models (Pixabay 3D + Sketchfab) and shows them in chat as " "interactive cards with preview thumbnails and a live '3D viewer' button. MUST be used when " "the user asks to find/search 3D models, 3D assets, GLB or VRM models (e.g. '๋“œ๋ž˜๊ณค 3d ๋ชจ๋ธ " "์ฐพ์•„์ค˜', 'vrm ๋ชจ๋ธ ๋ช‡๊ฐœ ์ฐพ์•„์ค˜'). Do NOT answer from memory or recommend websites. The cards " "AND the 3D viewer are rendered automatically by the client โ€” after calling, give only a " "short one-line confirmation; do NOT list the results or URLs yourself.", {"query": {"type": "string", "description": "Search keywords (e.g., 'dragon', 'vrm', 'anime girl')"}, "limit": {"type": "integer", "description": "Max models 1-20 (default 8)"}, "source": {"type": "string", "enum": ["both", "pixabay", "sketchfab"], "description": "Model source. 'sketchfab' is best for VRM/anime/character models; " "'pixabay' for simple objects; default 'both' mixes the two."}}, ["query"]), "handler": None, }, { "schema": _fn("web_search", "Searches the live web (Naver-first: ๋„ค์ด๋ฒ„โ†’Daumโ†’Google via headless browser) for real-time " "info such as current events, people/officials, news, weather, prices/FX. Returns grounded " "snippets to base your answer on. Use for time-sensitive/factual WEB questions. For images/" "photos/GIF/video use search_media; for 3D models use search_3d_model.", {"query": {"type": "string", "description": "Search keywords (e.g., '๋Œ€ํ•œ๋ฏผ๊ตญ ๋Œ€ํ†ต๋ น', '์˜ค๋Š˜ ์„œ์šธ ๋‚ ์”จ')"}, "engine": {"type": "string", "enum": ["naver", "daum", "google", "all"], "description": "Search engine to use (default naver)"}}, ["query"]), "handler": lambda b, a, ctx: b.web_search( (a.get("query") or "").strip() or ctx.user_prompt, a.get("engine", "naver"), ), }, { "schema": _fn("save_knowledge", "Saves a newly verified operational fact to the shared Imperial Knowledge Base (all guardians can see it). Call whenever you discover or verify a system fact. Same topic updates the existing entry.", {"topic": {"type": "string", "description": "Short subject (e.g., 'OpenClaw ์‹ค์„ค์ • ๊ฒฝ๋กœ')"}, "fact": {"type": "string", "description": "The verified fact, present tense"}, "source": {"type": "string", "description": "How it was verified"}, "scope": {"type": "string", "enum": ["global", "environment"], "description": "'global' if true on any PC (product behavior), 'environment' if only true on this machine (local paths, running ports)"}, "verify_hint": {"type": "string", "description": "How to re-verify in a new environment (e.g., 'check_port 18789', 'find_process openclaw')"}}, ["topic", "fact"]), "handler": lambda b, a, ctx: b.save_knowledge(a.get("topic", ""), a.get("fact", ""), a.get("source", ""), a.get("scope", "global"), a.get("verify_hint", "")), }, { "schema": _fn("check_port", "Read-only: checks if a local TCP port is listening RIGHT NOW and which process owns it. MUST be used to verify environment-scoped knowledge (ports/services) before asserting it as current fact.", {"port": {"type": "integer", "description": "TCP port to check (e.g., 18789)"}}, ["port"]), "handler": lambda b, a, ctx: b.check_port(a.get("port", 0)), }, { "schema": _fn("find_process", "Read-only: finds running processes matching a keyword and shows their command lines (reveals actual config paths in use). Use to verify which install/config a running service actually uses.", {"keyword": {"type": "string", "description": "Substring to match in process name/command line (e.g., 'openclaw')"}, "limit": {"type": "integer", "description": "Max processes (default 5)"}}, ["keyword"]), "handler": lambda b, a, ctx: b.find_process(a.get("keyword", ""), a.get("limit", 5)), }, { "schema": _fn("review_knowledge_proposals", "[Curator] Lists pending knowledge proposals from other guardians. You are the Imperial Knowledge librarian โ€” verify each with observation tools before approving."), "handler": lambda b, a, ctx: b.review_knowledge_proposals(), }, { "schema": _fn("judge_knowledge_proposal", "[Curator] Approves/rejects a pending knowledge proposal. ONLY approve after verifying the fact yourself. Approval replaces existing approved fact on the same topic.", {"proposal_id": {"type": "string", "description": "Proposal id from review_knowledge_proposals"}, "approve": {"type": "boolean", "description": "true=approve, false=reject"}, "note": {"type": "string", "description": "Verification basis or rejection reason"}}, ["proposal_id", "approve"]), "handler": lambda b, a, ctx: b.judge_knowledge_proposal(a.get("proposal_id", ""), a.get("approve", False), a.get("note", "")), }, { "schema": _fn("count_history", "Counts real chat history records in the DB. MUST be used for 'how many times / since when' questions. Never guess numbers.", {"query": {"type": "string", "description": "Text to count occurrences of. Empty counts all messages."}, "message_type": {"type": "integer", "description": "0=user only, 1=AI only, -1=all (default)"}}), "handler": lambda b, a, ctx: b.count_history(a.get("query", ""), a.get("message_type", -1)), }, { "schema": _fn("hugin_navigate", "Navigates the Master's visible browser to a specific URL.", {"url": {"type": "string", "description": "The URL to navigate to (e.g., 'https://www.google.com')"}}, ["url"]), "handler": lambda b, a, ctx: b.browser.hugin_navigate(a.get("url")), }, { "schema": _fn("hugin_screenshot", "Takes a screenshot of the Master's current visible browser state."), "handler": lambda b, a, ctx: b.browser.hugin_screenshot(), }, { "schema": _fn("hugin_click", "Clicks an element containing the specified text in the visible browser.", {"text": {"type": "string", "description": "The text of the element to click."}}, ["text"]), "handler": lambda b, a, ctx: b.browser.hugin_click(a.get("text")), }, { "schema": _fn("hugin_type", "Types text into the currently active or focused element in the visible browser.", {"text": {"type": "string", "description": "The text to type."}}, ["text"]), "handler": lambda b, a, ctx: b.browser.hugin_type(a.get("text")), }, { "schema": _fn("sovereign_type_text", "Types text directly into the Master's active window (OS level).", {"text": {"type": "string", "description": "The text to type (e.g., chat message)."}}, ["text"]), "handler": lambda b, a, ctx: b.sovereign_type_text(a.get("text")), }, { "schema": _fn("sovereign_press_key", "Presses a keyboard key directly in the Master's OS. Use 'enter' to submit a chat message after typing.", {"keys": {"type": "string", "description": "The key to press (e.g., 'enter', 'esc')."}}, ["keys"]), "handler": lambda b, a, ctx: b.sovereign_press_key(a.get("keys")), }, { "schema": _fn("sovereign_run_app", "Launches an OS application directly (e.g. 'notepad', 'calc').", {"app_name": {"type": "string", "description": "The name or command to run."}}, ["app_name"]), "handler": lambda b, a, ctx: b.sovereign_run_app(a.get("app_name")), }, { "schema": _fn("delegate_team_task", "Records a formal task for a specialized guardian to pick up later.", {"title": {"type": "string", "description": "Brief task title"}, "assignee_guardian": {"type": "string", "description": "Guardian name (e.g., 'Acubens', 'Hermes')"}, "description": {"type": "string", "description": "Detailed requirements for the task"}, "priority": {"type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"]}}, ["title", "assignee_guardian"]), "handler": lambda b, a, ctx: b.delegate_team_task(a.get("title"), a.get("assignee_guardian"), a.get("description", ""), a.get("priority", "MEDIUM")), }, { "schema": _fn("dispatch_to_hydra", "Immediately dispatches a goal to Hydra for parallel execution and synthesized reporting.", {"mission_goal": {"type": "string", "description": "The mission objective (e.g., 'Develop a python script and verify its output')"}}, ["mission_goal"]), "handler": lambda b, a, ctx: b.dispatch_to_hydra(a.get("mission_goal")), }, ] # ๐ŸŽฌ ๋ชจ์…˜๊ทธ๋ž˜ํ”ฝ ๋””์ž์ด๋„ˆ ๋„๊ตฌ 6์ข…(์ƒํ™ฉํ˜• โ€” ๋ชจ์…˜ ์˜๋„ยท์ง์—… ํ—ˆ์šฉ ๋•Œ๋งŒ ๋…ธ์ถœ). ์‹คํ–‰์€ motion_studio.py. try: from .tool_runtime_motion import MOTION_TOOLS, MOTION_TOOL_NAMES as _MOTION_TOOLS, wants_motion except ImportError: # ํŒจํ‚ค์ง€ ๋ฐ– ๋‹จ๋… ๋กœ๋“œ(verify_tool_runtime) โ€” ๊ฐ™์€ ํด๋” ํŒŒ์ผ์„ ์ง์ ‘ ์ฝ๋Š”๋‹ค import importlib.util as _ilu import os as _os_m _spec_m = _ilu.spec_from_file_location( "tool_runtime_motion_std", _os_m.path.join(_os_m.path.dirname(__file__), "tool_runtime_motion.py")) _mod_m = _ilu.module_from_spec(_spec_m) _spec_m.loader.exec_module(_mod_m) MOTION_TOOLS, _MOTION_TOOLS, wants_motion = _mod_m.MOTION_TOOLS, _mod_m.MOTION_TOOL_NAMES, _mod_m.wants_motion NATIVE_TOOLS += MOTION_TOOLS # ๐Ÿ—‚๏ธ ์˜ต์‹œ๋””์–ธ ์ง€์‹ ๋ ˆ์ด์–ด ๋ฐ Graphify โ€” ๋กœ์ปฌ ๋˜๋Š” ํด๋ผ์šฐ๋“œ ๋ณผํŠธ ํ™œ์„ฑ ์‹œ ๋…ธ์ถœ. # core๋ฅผ importํ•˜์ง€ ์•Š๋Š” ๊ทœ์น™์€ ์œ ์ง€: obsidian_router๋Š” ๋…๋ฆฝ ๋ชจ๋“ˆ์ด๋ผ ์ˆœํ™˜ ์—†์Œ. try: import obsidian_router as _ov if _ov.is_any_enabled(): NATIVE_TOOLS += [ { "schema": _fn("obsidian_search", "Searches the Obsidian vault (hand-curated/distilled knowledge in markdown notes) โ€” " "a separate layer from conversation memory. Use to find organized notes, design docs and " "reference material the user has written. Returns note paths + snippets. Works in both Local/Cloud. " "By default searches the active vault (local when running locally). Pass source='cloud' to search the " "R2 cloud vault via PGVector even while running locally (ํŠธ๋ฆฌ๊ฑฐ: 'ํด๋ผ์šฐ๋“œ ์˜ต์‹œ๋””์–ธ/R2์—์„œ ์ฐพ์•„์ค˜').", {"query": {"type": "string", "description": "Keywords to search in note titles/bodies"}, "limit": {"type": "integer", "description": "Max notes (default 8)"}, "source": {"type": "string", "enum": ["local", "cloud"], "description": "Which vault to search. Omit for active vault; 'cloud' = R2/PGVector."}}, ["query"]), "handler": lambda b, a, ctx: b.obsidian_search(a.get("query", ""), a.get("limit", 8), a.get("source")), }, { "schema": _fn("obsidian_read", "Reads the full body of one Obsidian note by its vault-relative path (from obsidian_search). Works in both " "Local/Cloud. Pass source='cloud' to read from the R2 cloud vault even while running locally.", {"path": {"type": "string", "description": "Vault-relative path, e.g. 'projects/jarvis.md'"}, "source": {"type": "string", "enum": ["local", "cloud"], "description": "Which vault to read from. Omit for active vault; 'cloud' = R2."}}, ["path"]), "handler": lambda b, a, ctx: b.obsidian_read(a.get("path", ""), a.get("source")), }, { "schema": _fn("obsidian_write", "Creates/updates a markdown note in the Obsidian vault. MUST be called โ€” and ACTUALLY " "CALLED, not merely promised โ€” whenever the user asks to create/write/save/organize/record a " "note, wiki, document, page, or memo in Obsidian (ํŠธ๋ฆฌ๊ฑฐ: '์˜ต์‹œ๋””์–ธ์— ~ ๋งŒ๋“ค์–ด/์ž‘์„ฑ/์ •๋ฆฌ/์ €์žฅํ•ด์ค˜', " "'์œ„ํ‚ค ๋งŒ๋“ค์–ด์ค˜', '๋…ธํŠธ๋กœ ์ •๋ฆฌํ•ด์ค˜', '๋ฌธ์„œ ์ž‘์„ฑํ•ด์ค˜'). CRITICAL: NEVER reply '๊ณง ๋งŒ๋“ค๊ฒ ์Šต๋‹ˆ๋‹ค/์ƒ์„ฑํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค' " "without calling this tool in the SAME turn โ€” a promise with no tool call creates nothing and is a " "failure. YOU must generate the FULL markdown body yourself (use Obsidian syntax: # headings, " "[[backlinks]], #tags, - lists) and pass it in 'content'. For a multi-page wiki, call this tool " "repeatedly (one call per note) and link them with [[ ]]. If unsure whether a note exists, call " "obsidian_search first, then write with mode='overwrite' or 'append' as appropriate. Works in both Local/Cloud.", {"path": {"type": "string", "description": "Vault-relative path (e.g. 'Wiki/ํƒœ๋ฏผ๊ฒŒ์ž„์ฆˆ/index.md'); '.md' auto-added"}, "content": {"type": "string", "description": "FULL markdown body of the note (you write it, not a placeholder)"}, "mode": {"type": "string", "enum": ["create", "overwrite", "append"], "description": "create=fail if exists (default); overwrite=replace; append=add to end"}}, ["path", "content"]), "handler": lambda b, a, ctx: b.obsidian_write(a.get("path", ""), a.get("content", ""), a.get("mode", "create")), }, { "schema": _fn("project_graph_query", "Queries the Graphify knowledge graph (project map) of the code and documentation structure. " "Use to check file import dependencies, class methods, and document references to understand " "the project layout and avoid reading raw code files blindly.", {"query": {"type": "string", "description": "Name of the file, module, or class to query (e.g., 'web_server.py', 'CodingToolkit')"}, "mode": {"type": "string", "enum": ["depends_on", "details", "all"], "description": "depends_on=import relations (default); details=class/file metadata; all=overall graph summary"}}, ["query"]), "handler": lambda b, a, ctx: b.project_graph_query(a.get("query", ""), a.get("mode", "depends_on")), }, ] logger.info("[ToolRuntime] ์˜ต์‹œ๋””์–ธ ๋ฐ ์ง€์‹ ๊ทธ๋ž˜ํ”„ ๋ ˆ์ด์–ด ํ™œ์„ฑ โ€” obsidian ๋ฐ project_graph_query ๋…ธ์ถœ") except Exception as _e: logger.warning(f"[ToolRuntime] ์˜ต์‹œ๋””์–ธ/๊ทธ๋ž˜ํ”„ ๋„๊ตฌ ๋“ฑ๋ก ๊ฑด๋„ˆ๋œ€: {_e}") # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # ๐Ÿงฉ ์œ ์ € ๋„๊ตฌ (user_tools/) โ€” ๋ฐฐํฌํŒ ์ปค์Šคํ…€ ํ™•์žฅ์  (2026-07-17) # # ์œ ์ €๊ฐ€ ์ž๊ธฐ ๋„ค์ดํ‹ฐ๋ธŒ ๋„๊ตฌ๋ฅผ '์šฐ๋ฆฌ ์†Œ์Šค ๋ฐ–'(์œ ์ € ์˜์—ญ)์— ๋‘๊ณ  ์“ฐ๊ฒŒ ํ•œ๋‹ค. # ์šฐ๋ฆฌ ํŠธ๋ฆฌ ์•ˆ์— ์ฝ”๋“œ๋ฅผ ๋„ฃ์œผ๋ฉด ์—…๋ฐ์ดํŠธ๊ฐ€ ๋ฎ์–ด์“ฐ๋Š” ๋ฌธ์ œ์˜ ํ•ด๋ฒ• โ€” # ComfyUI custom_nodes / ์˜ต์‹œ๋””์–ธ ํ”Œ๋Ÿฌ๊ทธ์ธ๊ณผ ๊ฐ™์€ ํŒจํ„ด. # # ๊ณ„์•ฝ(ํŒŒ์ผ๋‹น ๋„๊ตฌ 1๊ฐœ): ๋ชจ๋“ˆ ์ƒ์ˆ˜ TOOL = {name, description, params, required} # + ํ•จ์ˆ˜ run(args: dict) -> str. '_' ๋กœ ์‹œ์ž‘ํ•˜๋Š” ํŒŒ์ผ์€ ๋ฌด์‹œ(๋น„ํ™œ์„ฑ). # ์•ˆ์ „ํŒ: ์ด๋ฆ„ ๊ทœ์น™ยท๋‚ด์žฅ ๋„๊ตฌ์™€ ์ถฉ๋Œ ๊ฑฐ๋ถ€ยทํŒŒ์ผ๋ณ„ ์˜ˆ์™ธ ๊ฒฉ๋ฆฌ(๊นจ์ง„ ๋„๊ตฌ๋Š” ๊ฑด๋„ˆ๋œ€, # ๋ฐ๋ชฌ์€ ์ฃฝ์ง€ ์•Š์Œ). ๋ฐ˜์˜์€ ๋ฐ๋ชฌ ์žฌ์‹œ์ž‘ ์‹œ. # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ _USER_TOOL_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{2,40}$") # ๐Ÿงพ ์œ ์ € ๋„๊ตฌ ๊ณ„์•ฝ ๋ฒ„์ „ โ€” run(args)->str + TOOL dict ํ˜•ํƒœ๊ฐ€ v1. # ์šฐ๋ฆฌ๊ฐ€ ๊ณ„์•ฝ์„ ๋ฐ”๊พธ๋Š” ๋ฐฐํฌ๋ฅผ ํ•˜๋ฉด ์ด ์ˆซ์ž๋ฅผ ์˜ฌ๋ฆฌ๊ณ , ๊ตฌ/์‹  ๋ฒ„์ „ ๋„๊ตฌ์— # ๋ช…ํ™•ํ•œ ์•ˆ๋‚ด๋ฅผ ๋‚จ๊ธด๋‹ค ("์†Œ์Šค๊ฐ€ ๋ฐ”๋€Œ๋ฉด ์œ ์ €๊ฐ€ ์ž๊ธฐ ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ์–ธ์ œ ๊ณ ์ณ์•ผ # ํ•˜๋Š”์ง€"๋ฅผ ๊ธฐ๊ณ„์ ์œผ๋กœ ์•Œ๋ฆฌ๋Š” ์žฅ์น˜). USER_TOOLS_CONTRACT = 1 _USER_TOOLS_CONTRACT_MIN = 1 # ์ด๋ณด๋‹ค ๋‚ฎ์€ ๊ณ„์•ฝ์€ ๋” ์ด์ƒ ์ง€์›ํ•˜์ง€ ์•Š์Œ # ๐Ÿฉบ ๋งˆ์ง€๋ง‰ ์Šค์บ”์˜ ์‹คํŒจ ๊ธฐ๋ก โ€” ์ƒํƒœ API(/api/shadow/user-tools/status)๊ฐ€ ๋…ธ์ถœํ•ด # "์กฐ์šฉํžˆ ์•ˆ ์‹ค๋ฆฌ๋Š”" ์œ ์ € ๋„๊ตฌ๋ฅผ ๊ฐ€์‹œํ™”ํ•œ๋‹ค. [{file, error}] USER_TOOL_ERRORS: list = [] _USER_TOOLS_README = """# ๐Ÿงฉ Shadow Brain ์œ ์ € ๋„๊ตฌ (user_tools) ์ด ํด๋”์˜ `*.py` ํŒŒ์ผ์€ ์‰๋„์šฐ ๋ธŒ๋ ˆ์ธ ๋ถ€ํŒ… ์‹œ ์ž๋™์œผ๋กœ ๋„๊ตฌ๋กœ ๋“ฑ๋ก๋ฉ๋‹ˆ๋‹ค. ์—…๋ฐ์ดํŠธ๊ฐ€ ์ด ํด๋”๋ฅผ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์œผ๋ฏ€๋กœ, ์ปค์Šคํ…€ ๋„๊ตฌ๋Š” ์—ฌ๊ธฐ์— ๋‘์„ธ์š”. ## ๊ณ„์•ฝ (ํŒŒ์ผ ํ•˜๋‚˜ = ๋„๊ตฌ ํ•˜๋‚˜) ```python TOOL = { "name": "my_tool", # ์†Œ๋ฌธ์ž/์ˆซ์ž/_ (3~41์ž), ๋‚ด์žฅ ๋„๊ตฌ์™€ ์ค‘๋ณต ๋ถˆ๊ฐ€ "description": "์ด ๋„๊ตฌ๊ฐ€ ๋ฌด์—‡์„ ํ•˜๊ณ  ์–ธ์ œ ์จ์•ผ ํ•˜๋Š”์ง€ โ€” AI๊ฐ€ ์ฝ๋Š” ์„ค๋ช…์„œ", "params": {"q": {"type": "string", "description": "์งˆ์˜"}}, "required": ["q"], } def run(args: dict) -> str: # ์‹คํ–‰๋ถ€ โ€” ๊ฒฐ๊ณผ๋ฅผ ๋ฌธ์ž์—ด๋กœ ๋ฐ˜ํ™˜ return f"๊ฒฐ๊ณผ: {args.get('q')}" ``` ## ๊ทœ์น™ - `_` ๋กœ ์‹œ์ž‘ํ•˜๋Š” ํŒŒ์ผ์€ ๋ฌด์‹œ๋ฉ๋‹ˆ๋‹ค (`_draft.py` ๋กœ ๋น„ํ™œ์„ฑํ™”). - ๋ฐ˜์˜์—๋Š” ์‰๋„์šฐ ๋ธŒ๋ ˆ์ธ ์žฌ์‹œ์ž‘์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. - ๊นจ์ง„ ๋„๊ตฌ๋Š” ๊ฒฝ๊ณ  ๋กœ๊ทธ๋ฅผ ๋‚จ๊ธฐ๊ณ  ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค (๋ฐ๋ชฌ์€ ์ฃฝ์ง€ ์•Š์Œ). - ์ด ํด๋”์˜ ์ฝ”๋“œ๋Š” ๋ณธ์ธ ์ฑ…์ž„์œผ๋กœ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. - ์ง์—…(Jobs) ๊ทœ์น™์— "์ด ๋„๊ตฌ๋ฅผ ํ˜ธ์ถœํ•˜๋ผ"๋ฅผ ์ ์œผ๋ฉด ๋กœ์ปฌ AI๊ฐ€ ์‚ฌ์šฉ๋ฒ•์„ ๋ฐฐ์›๋‹ˆ๋‹ค. ## ๊ณ„์•ฝ ๋ฒ„์ „ (์—…๋ฐ์ดํŠธ ํ˜ธํ™˜) - ํ˜„์žฌ ๊ณ„์•ฝ: **v1** (์œ„ ํ˜•ํƒœ). TOOL ์— `"contract": 1` ์„ ๋ช…์‹œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค(์ƒ๋žต ์‹œ 1). - ์‰๋„์šฐ ๋ธŒ๋ ˆ์ธ ์—…๋ฐ์ดํŠธ๋กœ ๊ณ„์•ฝ์ด ๋ฐ”๋€Œ๋ฉด, ๊ตฌ๋ฒ„์ „ ๋„๊ตฌ๋Š” ๋กœ๋“œ ์‹œ ๊ฐฑ์‹  ์•ˆ๋‚ด์™€ ํ•จ๊ป˜ ๊ฑด๋„ˆ๋›ฐ์–ด์ง‘๋‹ˆ๋‹ค โ€” ์ด README ์˜ ์ตœ์‹  ๊ณ„์•ฝ์— ๋งž์ถฐ ๋„๊ตฌ๋ฅผ ๊ณ ์ณ ์ฃผ์„ธ์š”. - ๋„๊ตฌ ์ƒํƒœ ํ™•์ธ: `GET http://localhost:18700/api/shadow/user-tools/status` ## ์ง์—…(Jobs)๊ณผ ์—ฐ๊ฒฐ ์ง์—… ๋…ธํŠธ frontmatter ์— `user_tools: [๋‚ด๋„๊ตฌ์ด๋ฆ„]` ์„ ์„ ์–ธํ•˜๋ฉด: - ์ง์—… ๊ด€๋ฆฌ์ž์— ๐Ÿงฉ ํ”Œ๋Ÿฌ๊ทธ์ธ ๋ฐฐ์ง€๊ฐ€ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค. - ๋„๊ตฌ๊ฐ€ ์‚ญ์ œ/๊ณ ์žฅ๋‚œ ์ฑ„ ์ง์—…์ด ์žฅ์ฐฉ๋˜๋ฉด, AI ์—๊ฒŒ "๊ทธ ๋„๊ตฌ๋Š” ์ง€๊ธˆ ์—†๋‹ค"๋Š” ๊ฒฝ๊ณ ๊ฐ€ ์ž๋™ ์ฃผ์ž…๋˜์–ด ํ—›ํ˜ธ์ถœ ๋Œ€์‹  ์‚ฌ์šฉ์ž์—๊ฒŒ ์„ค์น˜ ํ•„์š”๋ฅผ ์•Œ๋ฆฝ๋‹ˆ๋‹ค. ## ์‹œ์ž‘ํ•˜๊ธฐ ์˜†์˜ `_example_disk_free.py` ๊ฐ€ ์™„์ „ํ•œ ์˜ˆ์ œ์ž…๋‹ˆ๋‹ค โ€” ์—ด์–ด ๋ณด๊ณ , ํŒŒ์ผ๋ช…์˜ `_` ๋ฅผ ์ง€์šฐ๋ฉด ์‹ค์ œ ๋„๊ตฌ๋กœ ์ผœ์ง‘๋‹ˆ๋‹ค. """ # ๐ŸŽ ์ฒซ ์‹คํ–‰ ๋•Œ ํ•จ๊ป˜ ์ƒ์„ฑ๋˜๋Š” ์˜ˆ์ œ ํ”Œ๋Ÿฌ๊ทธ์ธ โ€” '_' ์ ‘๋‘์‚ฌ๋ผ ๋น„ํ™œ์„ฑ(ํ•™์Šต์šฉ). # ์œ ์ €๊ฐ€ ์ ‘๋‘์‚ฌ๋ฅผ ์ง€์šฐ๋Š” ์ˆœ๊ฐ„๋ถ€ํ„ฐ ์‹ค์ œ ๋„๊ตฌ๊ฐ€ ๋œ๋‹ค. _USER_TOOLS_EXAMPLE = '''"""์˜ˆ์ œ ์œ ์ € ๋„๊ตฌ โ€” ์ด ์ปดํ“จํ„ฐ์˜ ๋””์Šคํฌ ๋‚จ์€ ์šฉ๋Ÿ‰์„ ์•Œ๋ ค์ค€๋‹ค. ํŒŒ์ผ๋ช… ์•ž์˜ '_' ๋ฅผ ์ง€์šฐ๋ฉด(disk_free.py) ๋‹ค์Œ ์žฌ์‹œ์ž‘๋ถ€ํ„ฐ ์‹ค์ œ ๋„๊ตฌ๋กœ ๋“ฑ๋ก๋œ๋‹ค. ์ž๊ธฐ ๋„๊ตฌ๋ฅผ ๋งŒ๋“ค ๋•Œ ์ด ํŒŒ์ผ์„ ๋ณต์‚ฌํ•ด ๊ณ ์น˜๋ฉด ๋œ๋‹ค. """ TOOL = { "name": "disk_free", "description": ( "Checks free disk space on this computer. Use when the user asks " "about remaining disk space or storage (๋””์Šคํฌ/์ €์žฅ๊ณต๊ฐ„ ๋‚จ์€ ์šฉ๋Ÿ‰)." ), "params": {}, # ์ธ์ž๊ฐ€ ํ•„์š”ํ•˜๋ฉด: {"q": {"type": "string", "description": "..."}} "required": [], # ํ•„์ˆ˜ ์ธ์ž ์ด๋ฆ„ ๋ชฉ๋ก "contract": 1, # ๊ณ„์•ฝ ๋ฒ„์ „ (README ์ฐธ๊ณ , ์ƒ๋žต ์‹œ 1) } def run(args: dict) -> str: import shutil total, used, free = shutil.disk_usage("/") gb = 1024 ** 3 return (f"๋””์Šคํฌ: ์ „์ฒด {total/gb:.0f}GB ยท ์‚ฌ์šฉ {used/gb:.0f}GB ยท " f"๋‚จ์Œ {free/gb:.0f}GB ({100*free/total:.0f}% ์—ฌ์œ )") ''' def _user_tools_dir(): import os return (os.environ.get("SHADOW_USER_TOOLS_DIR") or os.path.expanduser("~/TaeminGames/ShadowBrain/user_tools")) def load_user_tools(existing_names, directory=None): """user_tools/*.py ์Šค์บ” โ†’ ๊ฒ€์ฆ ํ†ต๊ณผํ•œ ๋„๊ตฌ ์—”ํŠธ๋ฆฌ ๋ชฉ๋ก ๋ฐ˜ํ™˜ (ํŒŒ์ผ๋ณ„ ์˜ˆ์™ธ ๊ฒฉ๋ฆฌ).""" import os import importlib.util d = directory or _user_tools_dir() try: first_run = not os.path.isdir(d) os.makedirs(d, exist_ok=True) readme = os.path.join(d, "README.md") if not os.path.exists(readme): with open(readme, "w", encoding="utf-8") as f: f.write(_USER_TOOLS_README) # ๐ŸŽ ์ฒซ ์‹คํ–‰(ํด๋” ์‹ ๊ทœ ์ƒ์„ฑ)์—๋งŒ ๋น„ํ™œ์„ฑ ์˜ˆ์ œ๋ฅผ ์‹ฌ๋Š”๋‹ค โ€” ์œ ์ €๊ฐ€ ์ง€์šฐ๋ฉด ์žฌ์ƒ์„ฑ ์•ˆ ํ•จ if first_run: with open(os.path.join(d, "_example_disk_free.py"), "w", encoding="utf-8") as f: f.write(_USER_TOOLS_EXAMPLE) except Exception as e: # noqa: BLE001 logger.warning(f"[UserTools] ํด๋” ์ค€๋น„ ์‹คํŒจ({d}): {e}") return [] entries = [] taken = set(existing_names) USER_TOOL_ERRORS.clear() for fn in sorted(os.listdir(d)): if not fn.endswith(".py") or fn.startswith("_"): continue path = os.path.join(d, fn) try: spec = importlib.util.spec_from_file_location(f"shadow_user_tools.{fn[:-3]}", path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) meta = getattr(mod, "TOOL", None) run = getattr(mod, "run", None) if not isinstance(meta, dict) or not callable(run): raise ValueError("TOOL dict ์™€ run(args) ํ•จ์ˆ˜๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค") name = str(meta.get("name") or "").strip() if not _USER_TOOL_NAME_RE.match(name): raise ValueError(f"์ด๋ฆ„ ๊ทœ์น™ ์œ„๋ฐ˜: '{name}' (์†Œ๋ฌธ์ž/์ˆซ์ž/_ 3~41์ž)") if name in taken: raise ValueError(f"์ด๋ฆ„ ์ถฉ๋Œ: '{name}' ์€ ์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋„๊ตฌ์ž…๋‹ˆ๋‹ค") contract = int(meta.get("contract") or 1) if contract > USER_TOOLS_CONTRACT: raise ValueError( f"๊ณ„์•ฝ v{contract} ๋„๊ตฌ โ€” ์ด ์‰๋„์šฐ ๋ธŒ๋ ˆ์ธ์€ v{USER_TOOLS_CONTRACT}๊นŒ์ง€ ์ง€์›. " "์‰๋„์šฐ ๋ธŒ๋ ˆ์ธ์„ ์—…๋ฐ์ดํŠธํ•˜์„ธ์š”") if contract < _USER_TOOLS_CONTRACT_MIN: raise ValueError( f"๊ณ„์•ฝ v{contract} ์€ ์ง€์› ์ข…๋ฃŒ(์ตœ์†Œ v{_USER_TOOLS_CONTRACT_MIN}) โ€” " "user_tools/README.md ์˜ ์ตœ์‹  ๊ณ„์•ฝ์œผ๋กœ ๋„๊ตฌ๋ฅผ ๊ฐฑ์‹ ํ•˜์„ธ์š”") desc = str(meta.get("description") or "").strip() if not desc: raise ValueError("description ์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค") entries.append({ "schema": _fn(name, desc, meta.get("params") or {}, list(meta.get("required") or [])), # ์œ ์ € ๋„๊ตฌ๋Š” brain ์ ‘๊ทผ ์—†์ด ์ธ์ž๋งŒ ๋ฐ›๋Š”๋‹ค (๊ณ„์•ฝ ๋‹จ์ˆœํ™” + ๊ฒฉ๋ฆฌ) "handler": (lambda m: lambda b, a, ctx: str(m.run(dict(a or {})))[:20000])(mod), "user_tool": True, "source": path, }) taken.add(name) logger.info(f"[UserTools] ๋“ฑ๋ก: {name} โ† {fn}") except Exception as e: # noqa: BLE001 logger.warning(f"[UserTools] '{fn}' ๋กœ๋“œ ์‹คํŒจ(๊ฑด๋„ˆ๋œ€): {e}") USER_TOOL_ERRORS.append({"file": fn, "error": str(e)[:300]}) return entries try: USER_TOOLS = load_user_tools( {e["schema"]["function"]["name"] for e in NATIVE_TOOLS}) except Exception as _ut_e: # noqa: BLE001 logger.warning(f"[UserTools] ์Šค์บ” ๊ฑด๋„ˆ๋œ€: {_ut_e}") USER_TOOLS = [] NATIVE_TOOLS += USER_TOOLS # ๐ŸงŠ yielding ๋„๊ตฌ: dispatch๊ฐ€ ์•„๋‹ˆ๋ผ run_tool_calls๊ฐ€ UI ๋ธ”๋ก์„ ์ŠคํŠธ๋ฆผ์— ์ง์ ‘ ๋ฐฉ์ถœ (handler=None ํ—ˆ์šฉ) _YIELDING_TOOLS = {"ask_user", "search_media", "search_3d_model", "search_youtube", "character_generate"} # ๐Ÿงน ์ƒํ™ฉํ˜• ๋„๊ตฌ: ํ•ด๋‹น ์˜๋„์ผ ๋•Œ๋งŒ ๋…ธ์ถœ (๊ทธ ์™ธ์—” ๋ชฉ๋ก์„ ์ž‘๊ฒŒ ์œ ์ง€ โ†’ ์•ฝํ•œ ๋ชจ๋ธ ์ •ํ™•๋„โ†‘) _HUGIN_TOOLS = {"hugin_navigate", "hugin_screenshot", "hugin_click", "hugin_type"} _OS_TOOLS = {"sovereign_type_text", "sovereign_press_key", "sovereign_run_app"} _DELEGATION_TOOLS = {"dispatch_to_hydra", "delegate_team_task"} class RuntimeCtx: """ํ•œ ํ„ด(์š”์ฒญ) ๋™์•ˆ์˜ ๋„๊ตฌ ๋ฃจํ”„ ์ƒํƒœ.""" def __init__(self, user_prompt, interactive=True, debug=False, display_name="Brain"): self.user_prompt = (user_prompt or "") self.interactive = interactive self.debug = debug self.display_name = display_name # ๋ฃจํ”„ ์ƒํƒœ self.used_tools = False # ๐Ÿ”ฑ Empty-Final Guard self.nudged_final = False self.ask_rescued = False # ๐Ÿ›Ÿ Fake-Ask Rescue (ํ„ด๋‹น 1ํšŒ) self.blocked_ask = 0 # ๐Ÿ›ก๏ธ Ask Circuit-Breaker ๋ˆ„์  self.force_no_tools = False # ํŠธ๋ฆฝ๋˜๋ฉด ๋„๊ตฌ ๋ฏธ๋ถ€์ฐฉ โ†’ ํ…์ŠคํŠธ ๋งˆ๋ฌด๋ฆฌ ๊ฐ•์ œ self.ask_attempted = False # ๐Ÿฝ ํ…์ŠคํŠธโ†’์นด๋“œ ๋ณ€ํ™˜ ๊ฒŒ์ดํŠธ self.expect_obsidian_write = False # ๐Ÿ—‚๏ธ ์˜ต์‹œ๋””์–ธ ์ž‘์„ฑ ์˜๋„ โ€” ์•ฝ์†๋งŒ ํ•˜๊ณ  ์•ˆ ์“ฐ๋ฉด ์žฌ์ด‰ self.obsidian_written = False # ๐Ÿ—‚๏ธ ์ด๋ฒˆ ํ„ด์— obsidian_write ๋ฅผ ์‹ค์ œ ํ˜ธ์ถœํ–ˆ๋Š”์ง€(์ค‘๋ณต ์ž‘์„ฑ ๋ฐฉ์ง€) self.promise_nudges = 0 # ๐Ÿ—‚๏ธ ์•ฝ์†-๋ฏธ์ดํ–‰ ์žฌ์ด‰ ํšŸ์ˆ˜(๋ฌดํ•œ๋ฃจํ”„ ๋ฐฉ์ง€) self.suppress_stream = False # ๐Ÿ”‡ ์นด๋“œ ๋ณ€ํ™˜ ๊ฐ€๋Šฅ์„ฑ ์žˆ๋Š” ํ…์ŠคํŠธ๋ฅผ ๋ผ์ด๋ธŒ ์ŠคํŠธ๋ฆฌ๋ฐํ•˜์ง€ ์•Š๊ณ  ๋ณด๋ฅ˜ class ToolRuntime: """3๊ฐœ OpenAI์‹ ๋Œ€ํ™” ๊ฒฝ๋กœ๊ฐ€ ๊ณต์œ ํ•˜๋Š” ๋„๊ตฌ ์‹คํ–‰ ๋Ÿฐํƒ€์ž„.""" def __init__(self, brain, is_debug=None, summarize=None, get_mcp_client=None): self.brain = brain self._is_debug = is_debug or (lambda: False) self._summarize = summarize self._get_mcp_client = get_mcp_client # ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ์—์„œ nameโ†’handler ๋งต ๊ตฌ์„ฑ (handler=None์ธ ask_user ์ œ์™ธ) self._handlers = { e["schema"]["function"]["name"]: e["handler"] for e in NATIVE_TOOLS if e["handler"] is not None } # ๐Ÿ”’ ์ž๊ฐ€๊ฒ€์ฆ: ๋…ธ์ถœ ์Šคํ‚ค๋งˆ โ†” ํ•ธ๋“ค๋Ÿฌ ์ •ํ•ฉ์„ฑ (yielding ๋„๊ตฌ๋Š” ํŠน์ˆ˜ ์ฒ˜๋ฆฌ๋กœ ํ—ˆ์šฉ) for e in NATIVE_TOOLS: nm = e["schema"]["function"]["name"] if e["handler"] is None and nm not in _YIELDING_TOOLS: logger.error(f"[ToolRuntime] '{nm}' ํ•ธ๋“ค๋Ÿฌ ๋ˆ„๋ฝ โ€” dispatch ๋ถˆ๊ฐ€") def native_schemas(self): """๋ชจ๋ธ์— ๋…ธ์ถœํ•  ๋„ค์ดํ‹ฐ๋ธŒ ๋„๊ตฌ OpenAI ์Šคํ‚ค๋งˆ ๋ฆฌ์ŠคํŠธ (๋‹จ์ผ ์ง„์‹ค ์ถœ์ฒ˜).""" return [e["schema"] for e in NATIVE_TOOLS] def user_tool_schemas(self): """๐Ÿงฉ user_tools/ ์œ ์ € ๋„๊ตฌ ์Šคํ‚ค๋งˆ โ€” ์„ค์น˜ ์ž์ฒด๊ฐ€ ์‚ฌ์šฉ ์˜๋„๋ผ ๋กœ์ปฌ ๊ฒฝ๋กœ (Ollama ํ๋ ˆ์ด์…˜)์— ํ•ญ์ƒ ๊ณ๋“ค์—ฌ ์‹ฃ๋Š”๋‹ค. ์—†์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ.""" return [e["schema"] for e in NATIVE_TOOLS if e.get("user_tool")] def _tool_default(self, tool_name, fb_limit, fb_max): """์ •์ฑ… tool_policy.tool_defaults.์˜ (limit, max). ์‹คํŒจ ์‹œ ๋‚ด์žฅ ํด๋ฐฑ.""" try: tp = getattr(self.brain, "_tool_policy", None) cfg = ((tp() if callable(tp) else {}).get("tool_defaults") or {}).get(tool_name) or {} lim = int(cfg.get("limit") or fb_limit) mx = int(cfg.get("max") or fb_max) return max(1, lim), max(1, mx) except Exception: # noqa: BLE001 return fb_limit, fb_max def ensure_declared_tools(self, tools, cfg): """๐ŸŽ–๏ธ ์ง์—…์ด `allowed_tools`(tool_mode=whitelist)๋กœ ์„ ์–ธํ•œ ๋„๊ตฌ๋ฅผ **ํ•ญ์ƒ** ์‹ค์–ด์ค€๋‹ค. โš ๏ธ [2026-07-31 ์‹ค์‚ฌ๊ณ ] Ollama ๊ฒฝ๋กœ๋Š” ๋„๊ตฌ๋ฅผ **์˜๋„๋กœ๋งŒ** ๋ถ™์ธ๋‹ค(`if _wants_wx:` ๋“ฑ). ๊ทธ๋ž˜์„œ ๊ธฐ์ƒ ์บ์Šคํ„ฐ๋ฅผ ์žฅ์ฐฉํ•˜๊ณ  ใ€Œ์•ˆ๋…•ใ€์ด๋ผ๊ณ  ํ•˜๋ฉด ๋‚ ์”จ ์˜๋„๊ฐ€ ์—†์–ด `get_weather` ๊ฐ€ ํ•œ ๊ฐœ๋„ ์•ˆ ์‹ค๋ ธ๊ณ (has_tools=False), ๋ชจ๋ธ์€ ์ง์—… ๊ทœ์น™๋Œ€๋กœ "์ง€๊ธˆ์€ ๋‚ ์”จ๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"๋ผ๊ณ  ์ •์งํ•˜๊ฒŒ ๋‹ตํ–ˆ๋‹ค โ€” ๊ฒŒ์œผ๋ฆ„์ด ์•„๋‹ˆ๋ผ **๋„๊ตฌ๋ฅผ ๋บ๊ธด ๊ฒƒ**์ด์—ˆ๋‹ค. ์ง์—…์ด ๋ช…์‹œํ•œ ๋„๊ตฌ๋Š” ๊ทธ ์ˆ˜ํ˜ธ์ž์˜ **์„ ์–ธ๋œ ๋Šฅ๋ ฅ**์ด๋‹ค. ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ๋Š” ์ƒํ•œ(ceiling) ์œผ๋กœ๋งŒ ์“ฐ์—ฌ ์™”์ง€๋งŒ(2026-07-20 ์ถ”๊ฐ€) ํ•˜ํ•œ(floor)์ด๊ธฐ๋„ ํ•ด์•ผ ํ•œ๋‹ค. ์˜๋„ ์ถ”์ธก์ด ์„ ์–ธ์„ ์ง€์šฐ๋ฉด ํด๋ฐฑ ๊ธˆ์ง€ ํ—Œ์žฅ("์ง์—… ์„ค์ •์€ ๋ฌธ์ž ๊ทธ๋Œ€๋กœ ์ง‘ํ–‰")์— ์–ด๊ธ‹๋‚œ๋‹ค. whitelist ๋ชจ๋“œ์ผ ๋•Œ๋งŒ ๋™์ž‘ํ•˜๊ณ (๋ฌด์„ ์–ธ ์ง์—…์€ ๊ธฐ์กด ํ๋ ˆ์ด์…˜ ์œ ์ง€), ์ด๋ฏธ ์‹ค๋ฆฐ ๋„๊ตฌ๋Š” ์ค‘๋ณต ์ถ”๊ฐ€ํ•˜์ง€ ์•Š๋Š”๋‹ค. ์ฑ„์›Œ ๋„ฃ์œผ๋ฉด ๋กœ๊ทธ๋ฅผ ๋‚จ๊ฒจ ์ง„๋‹จ ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•œ๋‹ค. """ if str((cfg or {}).get("tool_mode") or "all").lower() != "whitelist": return tools declared = [str(t).strip() for t in ((cfg or {}).get("allowed_tools") or []) if str(t).strip()] if not declared: return tools try: floor = self.curated_native_schemas({"only_tools": declared}) or [] except Exception as err: # noqa: BLE001 logger.debug(f"[์ง์—… ๋„๊ตฌ ํ•˜ํ•œ] ์Šคํ‚ค๋งˆ ์กฐํšŒ ์‹คํŒจ(๋ฌด์‹œ): {err}") return tools have = {s["function"]["name"] for s in (tools or [])} add = [s for s in floor if s["function"]["name"] not in have] if not add: return tools logger.info( "๐ŸŽ–๏ธ [์ง์—… ๋„๊ตฌ ํ•˜ํ•œ] ์„ ์–ธ ๋„๊ตฌ๋ฅผ ์˜๋„์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ ๋…ธ์ถœ: %s", [s["function"]["name"] for s in add], ) return (tools or []) + add def curated_native_schemas(self, flags): """์˜๋„๋ณ„ ํ๋ ˆ์ด์…˜. flags={'hugin','os','delegation','obsidian_only'}(bool). ํ•ด๋‹น ์˜๋„๊ฐ€ ์•„๋‹ˆ๋ฉด ์ƒํ™ฉํ˜• ๋„๊ตฌ(๋ธŒ๋ผ์šฐ์ €/OS์ œ์–ด/์œ„์ž„)๋ฅผ ์ˆจ๊ฒจ ๋ชฉ๋ก์„ ์ž‘๊ฒŒ ์œ ์ง€ํ•œ๋‹ค. ๋Œ€ํ™”ยท๊ฒ€์ƒ‰ยท๊ธฐ์–ตยท์ง€์‹ ๋„๊ตฌ๋Š” ํ•ญ์ƒ ๋…ธ์ถœ. obsidian_only=True: Ollama ์ „์šฉ ๊ฒฝ๋Ÿ‰ ๋ชจ๋“œ โ€” obsidian_write 1๊ฐœ๋งŒ ๋ฐ˜ํ™˜.""" # [๐Ÿ”ฑ Ollama ๊ฒฝ๋Ÿ‰ ๋ชจ๋“œ] ์ •์ฑ…์ด ์ •ํ•œ ๋„๊ตฌ ๋ฌถ์Œ๋งŒ ๋…ธ์ถœ (๋ฏธ๋””์–ด/3D/์œ ํŠœ๋ธŒ ๋“ฑ). # gemma4 ๋“ฑ tools ์ง€์› ๋กœ์ปฌ ๋ชจ๋ธ์ด 'vrm ๋ชจ๋ธ ์ฐพ์•„์ค˜'์— ํ™˜๊ฐ ๋‹ต๋ณ€ํ•˜๋˜ ๋ฌธ์ œ์˜ ํ•ด๋ฒ•: # ๋„๊ตฌ๋ฅผ ์•„์˜ˆ ์•ˆ ์‹ค์–ด์ฃผ๋˜ ๊ธฐ์กด ๋ถ„๊ธฐ ๋Œ€์‹  ์ตœ์†Œ ๋„๊ตฌ๋ฅผ ์‹ค์–ด์ค€๋‹ค. # only_tools: ์ด๋ฆ„ ๋ชฉ๋ก(์ •์ฑ… tool_policy.intents..tools). media_only๋Š” ๋ณ„์นญ. only = flags.get("only_tools") if flags.get("media_only") and not only: only = ["search_media", "search_3d_model", "search_youtube"] if only: keep = set(only) out = [e["schema"] for e in NATIVE_TOOLS if e["schema"]["function"]["name"] in keep] if out: return out # ์ •์ฑ… ์˜คํƒ€ ๋“ฑ์œผ๋กœ ๋งค์นญ 0์ด๋ฉด ์•ˆ์ „ ํด๋ฐฑ(๋„๊ตฌ 0๊ฐœ๋กœ ํ™˜๊ฐ ์œ ๋ฐœ ๋ฐฉ์ง€) keep = {"search_media", "search_3d_model", "search_youtube"} return [e["schema"] for e in NATIVE_TOOLS if e["schema"]["function"]["name"] in keep] # [๐Ÿ”ฑ Ollama ๊ฒฝ๋Ÿ‰ ๋ชจ๋“œ] ์•ฝํ•œ ๋กœ์ปฌ ๋ชจ๋ธ ์˜ค๋ฒ„๋กœ๋“œ ๋ฐฉ์ง€ if flags.get("obsidian_only"): for e in NATIVE_TOOLS: nm = e["schema"]["function"]["name"] if nm == "obsidian_write": return [e["schema"]] return [] out = [] for e in NATIVE_TOOLS: nm = e["schema"]["function"]["name"] if nm in _HUGIN_TOOLS and not flags.get("hugin"): continue if nm in _OS_TOOLS and not flags.get("os"): continue if nm in _DELEGATION_TOOLS and not flags.get("delegation"): continue if nm in _MOTION_TOOLS and not flags.get("motion"): continue out.append(e["schema"]) return out def _handle_search_media(self, args, ctx): query = (str(args.get("query") or "").strip()) or ctx.user_prompt media_type = str(args.get("media_type") or "photo").strip().lower() provider = str(args.get("provider") or "pexels").strip().lower() try: limit = max(1, min(20, int(args.get("limit", 8)))) except (TypeError, ValueError): limit = 8 if media_type not in ("photo", "video", "gif"): media_type = "photo" if provider not in ("pexels", "pixabay"): provider = "pexels" aegis_url, hits, err = self.brain.search_media_hits(query, media_type, provider, limit) if err: return err if not hits: return f"'{query}' {media_type} ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†๋‹ค. ๋‹ค๋ฅธ ๊ฒ€์ƒ‰์–ด๋ฅผ ์งง๊ฒŒ ์ œ์•ˆํ•˜๋ผ." items = [] for h in hits: dl = h.get("download", "") if isinstance(dl, str) and dl.startswith("/") and aegis_url: dl = aegis_url + dl items.append({ "source": h.get("source") or provider, "type": h.get("type") or media_type, "url": h.get("url") or "", # ๐Ÿ–ผ๏ธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ธ๋„ค์ผ โ€” ๋น„๋””์˜ค๋Š” url์ด mp4๋ผ ์ด๋ฏธ์ง€๋กœ ๋ชป ๊ทธ๋ฆฌ๋ฏ€๋กœ # ๋ณ„๋„ preview(์ธ๋„ค์ผ ์ด๋ฏธ์ง€)๋ฅผ ๋ฐ˜๋“œ์‹œ ํ•จ๊ป˜ ์ „๋‹ฌํ•œ๋‹ค. "preview": h.get("preview") or "", "download": dl, "page": h.get("page") or "", "title": h.get("title") or h.get("alt") or h.get("source") or "Media", }) payload = {"query": query, "media_type": media_type, "items": items} yield f"\n\n```imperial-media\n{json.dumps(payload, ensure_ascii=False)}\n```\n\n" return f"๋ฏธ๋””์–ด {len(items)}๊ฐœ๋ฅผ ์นด๋“œ ๊ทธ๋ฆฌ๋“œ๋กœ ํ™”๋ฉด์— ํ‘œ์‹œํ–ˆ๋‹ค. ๊ฒฐ๊ณผ๋ฅผ ๋‹ค์‹œ ๋‚˜์—ดํ•˜์ง€ ๋ง๊ณ  ํ•œ ์ค„๋กœ ๋งˆ๋ฌด๋ฆฌํ•˜๋ผ." def _handle_character_generate(self, args, ctx): """๐ŸŽจ ์บ๋ฆญํ„ฐ ์‹ค์ƒ์„ฑ + ์ฑ„ํŒ… ์ธ๋ผ์ธ ํ‘œ์‹œ โ€” ์ƒ์„ฑ ์ด๋ฏธ์ง€๋ฅผ imperial-media ์นด๋“œ๋กœ ์ŠคํŠธ๋ฆผ์— ์ง์ ‘ ๋ฐฉ์ถœํ•œ๋‹ค (๊ฐ•์•„์ง€ ์ด๋ฏธ์ง€ ๊ฒ€์ƒ‰๊ณผ ๊ฐ™์€ ๋ Œ๋” ๊ฒฝ๋กœ). ์ด๋ฏธ์ง€ ๋ฐ”์ดํŠธ๋Š” ์˜ต์‹œ๋””์–ธ ์ฒจ๋ถ€ API(/api/obsidian/attachment)๊ฐ€ ์„œ๋น™ํ•œ๋‹ค (2026-07-17).""" desc = (str(args.get("description") or "").strip()) or ctx.user_prompt mode = str(args.get("mode") or "explore") seed = args.get("seed") text, res = self.brain.character_generate_meta( desc, mode, seed, user_prompt=ctx.user_prompt or "") vault_path = (res or {}).get("vault_path") or "" if res and res.get("ok") and vault_path: from urllib.parse import quote url = ("http://localhost:18700/api/obsidian/attachment?path=" + quote(vault_path)) payload = {"query": desc[:60], "media_type": "photo", "items": [{ "source": "character_forge", "type": "photo", "url": url, "preview": url, "download": url, "page": "", "title": f"์ƒ์„ฑ ์บ๋ฆญํ„ฐ ยท seed {res.get('seed')} ({res.get('mode')})", }]} yield f"\n\n```imperial-media\n{json.dumps(payload, ensure_ascii=False)}\n```\n\n" return text def _handle_search_youtube(self, args, ctx): """๐ŸŽฅ ์‹ค์ œ ์œ ํŠœ๋ธŒ ๊ฒ€์ƒ‰ โ†’ imperial-youtube ์นด๋“œ ๋ธ”๋ก์„ ์ŠคํŠธ๋ฆผ์— ์ง์ ‘ ๋ฐฉ์ถœ.""" query = (str(args.get("query") or "").strip()) or ctx.user_prompt try: limit = max(1, min(20, int(args.get("limit", 6)))) except (TypeError, ValueError): limit = 6 result = self.brain.youtube_search(query, limit) # youtube_search ๋ฐ˜ํ™˜๋ฌธ์—์„œ imperial-youtube ๋ธ”๋ก๋งŒ ์ถ”์ถœํ•ด ์นด๋“œ๋กœ ๋ฐฉ์ถœํ•œ๋‹ค. m = re.search(r"```imperial-youtube[\s\S]*?```", result or "") if not m: # ํ‚ค ๋ฏธ์„ค์ •/๊ฒฐ๊ณผ์—†์Œ/์˜ค๋ฅ˜ โ†’ ๋ชจ๋ธ์— ๊ทธ๋Œ€๋กœ ์ „๋‹ฌ(์‚ฌ์šฉ์ž์—๊ฒŒ ์‚ฌ์œ  ์•ˆ๋‚ด) return result or "์œ ํŠœ๋ธŒ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค." yield f"\n\n{m.group(0)}\n\n" return f"์œ ํŠœ๋ธŒ ์˜์ƒ์„ ์นด๋“œ๋กœ ํ™”๋ฉด์— ํ‘œ์‹œํ–ˆ๋‹ค. ๊ฒฐ๊ณผ๋ฅผ ๋‹ค์‹œ ๋‚˜์—ดํ•˜์ง€ ๋ง๊ณ  ํ•œ ์ค„๋กœ ๋งˆ๋ฌด๋ฆฌํ•˜๋ผ." # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # ๋น„-yield ๋„๊ตฌ ๋””์ŠคํŒจ์น˜ (ask_user ์ œ์™ธ, ์ˆœ์ˆ˜ ํ•จ์ˆ˜) # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def dispatch(self, func_name, args, ctx): b = self.brain handler = self._handlers.get(func_name) if handler is not None: return handler(b, args, ctx) if b.coding.handles(func_name): return b.coding.execute(func_name, args) if func_name.startswith("mcp_"): return self._dispatch_mcp_tool(func_name, args, timeout=150, user_prompt=ctx.user_prompt) return f"Error: Tool '{func_name}' not found in Imperial Arsenal." # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # ๐ŸŒ MCP ๋„๊ตฌ ํ˜ธ์ถœ ๊ฐ€๋“œ (ํ•„์ˆ˜ ์ธ์ž ์‚ฌ์ „ ๊ฒ€์ฆยท์ž๋™ ๋ณด์ •) # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _dispatch_mcp_tool(self, func_name, args, timeout=150, user_prompt=None): """๊ฒฝ๋Ÿ‰ ๋ชจ๋ธ์ด `query` ๋“ฑ ํ•„์ˆ˜ string ์ธ์ž๋ฅผ ๋น„์šด ์ฑ„ MCP ๋„๊ตฌ๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด ๊ทธ๋Œ€๋กœ ๋ณด๋‚ด -32602๋กœ ๋–จ์–ด์ง€๊ณ  ๊ฐ™์€ ์‹ค์ˆ˜๋ฅผ ๋ฐ˜๋ณตํ•œ๋‹ค. 2๋‹จ๊ณ„๋กœ ๋ฐฉ์–ดํ•œ๋‹ค. 1) ๋ˆ„๋ฝ ํ•„์ˆ˜ string 1๊ฐœ + ์œ ์ € ๋ฐœํ™”๊ฐ€ ์žˆ์œผ๋ฉด ๊ทธ๊ฑธ๋กœ ์ฑ„์›Œ ์žฌ์‹œ๋„(๊ฒ€์ƒ‰ ์ผ€์ด์Šค ๊ตฌ์ œ) 2) ๋ณด์ • ๋ถˆ๊ฐ€ ์‹œ '๋ฌด์—‡์ด ๋น„์—ˆ๋Š”์ง€ + ์˜ˆ์‹œ'๋ฅผ ๊ฒฐ๊ณผ๋กœ ๋Œ๋ ค ๋ชจ๋ธ ์ž๊ฐ€ ๊ต์ • ์œ ๋„ """ _mc = self._get_mcp_client() if self._get_mcp_client else None if not _mc: return "Error: MCP unavailable." if not isinstance(args, dict): args = {} try: body = func_name[4:] # 'mcp_' ์ œ๊ฑฐ โ†’ '_' target = None for t in _mc.get_raw_tools(): if f"{t.get('server')}_{t.get('name')}" == body: target = t break # [๐Ÿ”ฑ Playwright Safety] wait_for ํ˜ธ์ถœ ์‹œ time/text/textGone ์ธ์ž๊ฐ€ ๋ชจ๋‘ ์—†์œผ๋ฉด time: 2000ms ์ž๋™ ์ฃผ์ž… (์—๋Ÿฌ ๋ฐฉ์ง€) if "playwright" in func_name and "wait" in func_name: if not args.get("time") and not args.get("text") and not args.get("textGone"): args["time"] = 2000 logger.info(f"[MCP Guard] '{func_name}' ์ธ์ž ๋ˆ„๋ฝ ์ž๋™ ๋ณด์ • โ†’ time: 2000ms ์ฃผ์ž…") if target: schema = target.get("input_schema") or {} required = schema.get("required", []) or [] props = schema.get("properties", {}) or {} missing = [f for f in required if args.get(f) is None or (isinstance(args.get(f), str) and not args.get(f).strip())] if missing: # 1) ์ž๋™ ๋ณด์ • if user_prompt and user_prompt.strip() and len(missing) == 1: f0 = missing[0] if (props.get(f0, {}) or {}).get("type", "string") == "string": # [๐Ÿ”ฑ Playwright Safety] browser_navigate๋Š” ์œ ์ € ๋ฐœํ™”๋กœ ๋ณด์ •ํ•˜๋ฉด URL์ด ๊นจ์ง€๋ฏ€๋กœ ์ œ์™ธ if func_name == "mcp_playwright_browser_navigate" and f0 == "url": return "ERROR: browser_navigate๋ฅผ ํ˜ธ์ถœํ•˜๋ ค๋ฉด ์œ ํšจํ•œ http/https URL ์ฃผ์†Œ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ ๋ฌธ์ž์—ด์„ ์ง์ ‘ ๋„ฃ์ง€ ๋งˆ์‹ญ์‹œ์˜ค." args[f0] = user_prompt.strip()[:400] logger.info(f"[MCP Guard] '{func_name}' ๋ˆ„๋ฝ ์ธ์ž '{f0}'๋ฅผ ์œ ์ € ๋ฐœํ™”๋กœ ์ž๋™ ๋ณด์ • โ†’ ์žฌ์‹œ๋„") return _mc.call_tool_by_gemini_name(func_name, args, timeout=timeout) # 2) ์žฌ์‹œ๋„ ์œ ๋„ example = {} for field in required: ptype = (props.get(field, {}) or {}).get("type", "string") example[field] = (0 if ptype in ("number", "integer") else True if ptype == "boolean" else ["..."] if ptype == "array" else "...") logger.warning(f"[MCP Guard] '{func_name}' ํ•„์ˆ˜ ์ธ์ž ๋ˆ„๋ฝ {missing} โ€” MCP ํ˜ธ์ถœ ์ฐจ๋‹จ") return (f"ERROR: ๋„๊ตฌ '{func_name}' ํ˜ธ์ถœ์— ํ•„์ˆ˜ ์ธ์ž {missing}๊ฐ€ ๋น„์–ด ์žˆ์–ด " f"MCP ์„œ๋ฒ„๋กœ ๋ณด๋‚ด์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์˜ฌ๋ฐ”๋ฅธ JSON ์ธ์ž๋ฅผ ์ฑ„์›Œ ๋‹ค์‹œ ํ˜ธ์ถœํ•˜์„ธ์š”. " f"์˜ˆ์‹œ: {json.dumps(example, ensure_ascii=False)}") except Exception as _ve: logger.warning(f"[MCP Guard] ์ธ์ž ๊ฒ€์ฆ ๊ฒฝ๊ณ ({func_name}): {_ve}") return _mc.call_tool_by_gemini_name(func_name, args, timeout=timeout) # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # ๐Ÿ›Ÿ๐Ÿฝ ํ…์ŠคํŠธโ†’ask ์นด๋“œ ๋ณต์› ํŒŒ์„œ (์ˆœ์ˆ˜ ํ•จ์ˆ˜) # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @staticmethod def _extract_fake_ask_payload(text): """๋ชจ๋ธ์ด ask_user ๋Œ€์‹  ๋ณธ๋ฌธ์— ์“ด {"question":...,"options":[...]} JSON์„ ์ฐพ์•„๋‚ธ๋‹ค. question+options(2๊ฐœ ์ด์ƒ)๋ฅผ ๊ฐ€์ง„ ์ฒซ JSON์„ ๋ฐ˜ํ™˜, ์—†์œผ๋ฉด None.""" for m in re.finditer(r"\{[^{}]*\}", text or ""): try: obj = json.loads(m.group(0)) except Exception: continue if (isinstance(obj, dict) and str(obj.get("question", "")).strip() and isinstance(obj.get("options"), list) and len(obj["options"]) >= 2): return obj return None @staticmethod def _extract_ask_from_markdown(text): """๋ชจ๋ธ์ด ์˜ต์…˜์„ ์ธ์ž๋กœ ๋ชป ๋„ฃ๊ณ  ๋ณธ๋ฌธ์— โ‘ โ‘กโ‘ข/๋ฒˆํ˜ธ/๋ถˆ๋ฆฟ ๋ชฉ๋ก์œผ๋กœ ์“ด ๊ฒฝ์šฐ, ๊ทธ ๋ชฉ๋ก์„ ํŒŒ์‹ฑํ•ด {question, options}๋กœ ๋ณต์›ํ•œ๋‹ค(2~6๊ฐœ). ์•„๋‹ˆ๋ฉด None.""" if not text: return None lines = text.splitlines() opt_re = re.compile(r'^(?:[' + _CIRCLED_NUM + r']|\(?\d{1,2}[.)])\s*(.+)$') bullet_re = re.compile(r'^[\-\*โ€ข]\s*') options = [] for ln in lines: s = ln.strip() if not s: continue s = bullet_re.sub('', s) # ์„ ํ–‰ ๋ถˆ๋ฆฟ ์ œ๊ฑฐ m = opt_re.match(s) # ๊ทธ ๋’ค โ‘ .. ๋˜๋Š” 1./1) ๋ฒˆํ˜ธ ํ•„์ˆ˜ if not m: continue label = re.sub(r'\*\*|__|`', '', m.group(1)) # ๋งˆํฌ๋‹ค์šด ๊ฐ•์กฐ ์ œ๊ฑฐ label = label.strip(' :๏ผš-โ€”ยท').strip() if label: options.append(label[:40]) if len(options) < 2: return None options = options[:6] question = "" for ln in lines: s = ln.strip() if not s.endswith('?'): continue if opt_re.match(bullet_re.sub('', s)): continue question = re.sub(r'^[^\w๊ฐ€-ํžฃ"\']+', '', s).strip() break if not question: question = "์–ด๋–ค ๊ฒƒ์œผ๋กœ ํ• ๊นŒ์š”?" return {"question": question[:200], "options": options} # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # ask_user (UI ์นด๋“œ ๋ฐฉ์ถœ + ๋Œ€๊ธฐ) โ€” ์ œ๋„ˆ๋ ˆ์ดํ„ฐ, ๊ฒฐ๊ณผ ๋ฌธ์ž์—ด return # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _handle_ask_user(self, args, ctx): ctx.ask_attempted = True if not ctx.interactive: # ๐Ÿšท ๋น„๋Œ€ํ™”ํ˜•(๋™๊ธฐ ๋ฆด๋ ˆ์ด) ๊ฒฝ๋กœ โ€” ์„ ํƒ์ง€ UI๊ฐ€ ์ œ๋•Œ ๋œฐ ์ˆ˜ ์—†์Œ return ("UNAVAILABLE: ์ด ์ฑ„๋„์€ ๋น„๋Œ€ํ™”ํ˜•(๋™๊ธฐ ๋ฆด๋ ˆ์ด) ๊ฒฝ๋กœ๋ผ ask_user๋ฅผ " "์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋‹ค. ๊ธฐ๋‹ค๋ฆฌ์ง€ ๋ง๊ณ , ๋‹ต๋ณ€ ๋ณธ๋ฌธ์— ์„ ํƒ์ง€๋ฅผ ํ…์ŠคํŠธ๋กœ ์ œ์‹œํ•œ " "๋’ค ํ„ด์„ ๋๋‚ด๋ผ. ์œ ์ €์˜ ๋‹ต์€ ๋‹ค์Œ ๋ฉ”์‹œ์ง€๋กœ ์˜จ๋‹ค.") # ๐Ÿ›ก๏ธ Ask Guard: ๋นˆ ์ธ์ž({})๋ฉด ์งˆ๋ฌธ/์˜ต์…˜์ด ๋นˆ ๊นจ์ง„ ์นด๋“œ๊ฐ€ ๋– ๋ฒ„๋ฆฐ๋‹ค โ†’ ์ฐจ๋‹จ _q = str(args.get("question", "")).strip() _opts = [str(o).strip() for o in (args.get("options") or []) if str(o).strip()] if not _q or len(_opts) < 2: ctx.blocked_ask += 1 logger.warning(f"[Ask Guard] ask_user ์ธ์ž ๋ถˆ์ถฉ๋ถ„ " f"(question={bool(_q)}, options={len(_opts)}) โ€” ๋นˆ ์นด๋“œ ์ฐจ๋‹จ ({ctx.blocked_ask}ํšŒ)") if ctx.blocked_ask >= 2: # ๐Ÿ›ก๏ธ Circuit-Breaker: ๋‘ ๋ฒˆ์งธ ๋นˆ ํ˜ธ์ถœ โ†’ ๋„๊ตฌ ๋น„ํ™œ์„ฑํ™”๋กœ ๋ฌดํ•œ ๋ฃจํ”„ ์ฐจ๋‹จ ctx.force_no_tools = True # ๐Ÿ”‡ ๋‹ค์Œ(๋„๊ตฌ ์—†๋Š”) ์‘๋‹ต์€ ์นด๋“œ๋กœ ๋ณ€ํ™˜๋  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ๋ผ์ด๋ธŒ ์ŠคํŠธ๋ฆฌ๋ฐ ๋ณด๋ฅ˜ ctx.suppress_stream = True logger.warning("[Ask Guard] ๋นˆ ask_user 2ํšŒ โ€” ์„œํ‚ท ๋ธŒ๋ ˆ์ด์ปค ์ž‘๋™, " "๋„๊ตฌ ๋น„ํ™œ์„ฑํ™” ํ›„ ํ…์ŠคํŠธ ๋งˆ๋ฌด๋ฆฌ ๊ฐ•์ œ") return ("STOP: ask_user๋ฅผ ๋นˆ ์ธ์ž๋กœ ๋‘ ๋ฒˆ ํ˜ธ์ถœํ–ˆ๋‹ค. ์ด์ œ ๋„๊ตฌ๋Š” ๋น„ํ™œ์„ฑํ™”๋œ๋‹ค. " "๋” ์ด์ƒ ๋„๊ตฌ๋ฅผ ๋ถ€๋ฅด์ง€ ๋ง๊ณ , ์œ ์ €์—๊ฒŒ ๋ฌด์—‡์„ ๋„์™€์ค„์ง€ ๋ณธ๋ฌธ ํ…์ŠคํŠธ๋กœ " "์ง์ ‘ ํ•œ๊ตญ์–ด๋กœ ๋ฌผ์–ด๋ณด๋ฉฐ ํ„ด์„ ๋๋‚ด๋ผ.") return ("ERROR: ask_user์—๋Š” ๋น„์–ด์žˆ์ง€ ์•Š์€ 'question'๊ณผ 2~6๊ฐœ์˜ 'options'๊ฐ€ " "๋ฐ˜๋“œ์‹œ ํ•„์š”ํ•˜๋‹ค. ์ธ์ž๊ฐ€ ๋น„์–ด ๋นˆ ์นด๋“œ๊ฐ€ ๋œจ๋ฏ€๋กœ ํ˜ธ์ถœ์„ ์ทจ์†Œํ–ˆ๋‹ค. ์˜ˆ์‹œ: " '{"question": "์–ด๋–ค ๋ฐฉ์‹์œผ๋กœ ์ง„ํ–‰ํ• ๊นŒ์š”?", "options": ["A์•ˆ", "B์•ˆ"]}. ' "์ •๋ง ์ œ์‹œํ•  ์„ ํƒ์ง€๊ฐ€ ์—†๋‹ค๋ฉด ๋„๊ตฌ ๋Œ€์‹  ๋ณธ๋ฌธ ํ…์ŠคํŠธ๋กœ ์งˆ๋ฌธํ•˜๊ณ  ํ„ด์„ ๋๋‚ด๋ผ.") # ์ •์ƒ: UI ์นด๋“œ ๋ฐฉ์ถœ โ†’ ์‘๋‹ต/ํƒ€์ž„์•„์›ƒ ๋Œ€๊ธฐ (์ œ๋„ˆ๋ ˆ์ดํ„ฐ ์œ„์ž„) return (yield from self._emit_ask_card(_q, _opts, args.get("timeout_seconds", 120), ctx)) def _emit_ask_card(self, question, options, timeout_seconds, ctx=None): _ask = self.brain._begin_ask_user({ "question": question, "options": options, "timeout_seconds": timeout_seconds, }) yield f"\n\n```imperial-ask\n{json.dumps(_ask, ensure_ascii=False)}\n```\n\n" _ans = self.brain._wait_ask_user(_ask["ask_id"], _ask["timeout_seconds"]) if _ans is None: yield t("โฑ๏ธ ์„ ํƒ ๋Œ€๊ธฐ ์‹œ๊ฐ„({sec}์ดˆ)์ด ์ดˆ๊ณผ๋˜์–ด ์งˆ๋ฌธ์ด ์ž๋™ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", sec=_ask['timeout_seconds']) + "\n\n" return ("TIMEOUT: ์œ ์ €๊ฐ€ ์ œํ•œ ์‹œ๊ฐ„ ๋‚ด์— ์„ ํƒํ•˜์ง€ ์•Š์•„ ์งˆ๋ฌธ์ด ์ทจ์†Œ๋˜์—ˆ๋‹ค. " "๋ณด์ˆ˜์ ์ธ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๋งˆ๋ฌด๋ฆฌํ•˜๊ฑฐ๋‚˜, ๊ฒฐ์ •์ด ํ•„์ˆ˜๋ผ๋ฉด ๋‹ต๋ณ€ ๋ง๋ฏธ์— ์งˆ๋ฌธ์„ ๋‚จ๊ธฐ๊ณ  ํ„ด์„ ๋๋‚ด๋ผ.") if _ans == "__CANCELLED__": yield t("๐Ÿšซ ์งˆ๋ฌธ์ด ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค (์œ ์ €๊ฐ€ ๋‹ค๋ฅธ ์ž‘์—…์œผ๋กœ ์ „ํ™˜).") + "\n\n" return ("CANCELLED: ์œ ์ €๊ฐ€ ์งˆ๋ฌธ์„ ์ทจ์†Œํ•˜๊ณ  ๋‹ค๋ฅธ ์ž‘์—…์œผ๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. " "์„ ํƒ์„ ๊ธฐ๋‹ค๋ฆฌ์ง€ ๋ง๊ณ  ๋ณด์ˆ˜์ ์œผ๋กœ ์งง๊ฒŒ ๋งˆ๋ฌด๋ฆฌํ•˜๋ผ.") routed = yield from self._route_search_menu_answer(question, options, _ans, ctx) if routed is not None: return routed return f"์œ ์ €์˜ ์„ ํƒ: {_ans}" def _route_search_menu_answer(self, question, options, answer, ctx=None): q = str(question or "") ans = str(answer or "") opt_list = [str(o or "") for o in (options or [])] joined = " ".join([q, ans] + opt_list) is_search_menu = ( any(k in joined for k in ("๊ฒ€์ƒ‰", "search", "๋„ค์ด๋ฒ„", "๋‹ค์Œ", "๊ตฌ๊ธ€", "์›น๊ฒ€์ƒ‰", "์ด์ง€์Šค", "๋ฏธ๋””์–ด", "์œ ํŠœ๋ธŒ", "youtube", "YouTube")) or (2 <= len(opt_list) <= 5 and ans in opt_list) ) if not is_search_menu: return None user_prompt = getattr(ctx, "user_prompt", "") if ctx is not None else "" query = self._clean_search_query(user_prompt) selected_index = opt_list.index(ans) if ans in opt_list else -1 # ๐ŸŽฅ ์œ ํŠœ๋ธŒ ์˜ต์…˜์ด ๋ผ๋ฉด ์˜ต์…˜ ๊ฐœ์ˆ˜๊ฐ€ 5๊ฐœ๊ฐ€ ๋˜์–ด ์ธ๋ฑ์Šค๊ฐ€ ๋ฐ€๋ฆฐ๋‹ค โ†’ # ์ธ๋ฑ์Šค ๊ธฐ๋ฐ˜ ๋ผ์šฐํŒ…์€ ์ •ํ™•ํžˆ 4์ง€์„ ๋‹ค(์œ ํŠœ๋ธŒ ์—†์Œ)์ผ ๋•Œ๋งŒ ์“ฐ๊ณ , ๊ทธ ์™ธ์—” ํ‚ค์›Œ๋“œ๋กœ ํŒ๋ณ„. four_way_menu = (len(opt_list) == 4) # ๐ŸŽฅ ์œ ํŠœ๋ธŒ: ์„ ํƒํ–ˆ๊ฑฐ๋‚˜, ์›๋ฌธ์— ์œ ํŠœ๋ธŒ๊ฐ€ ๋ช…์‹œ๋œ ๊ฒฝ์šฐ ๊ณง๋ฐ”๋กœ ์œ ํŠœ๋ธŒ ๊ฒ€์ƒ‰ if ( "์œ ํŠœ๋ธŒ" in ans or "youtube" in ans.lower() or (selected_index < 0 and ("์œ ํŠœ๋ธŒ" in user_prompt or "youtube" in (user_prompt or "").lower())) ): yield "\n\n" + t("๐ŸŽฅ ์œ ํŠœ๋ธŒ ๊ฒ€์ƒ‰์œผ๋กœ '{query}' ์ •์ฐฐ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.", query=query) + "\n\n" # imperial-youtube ์นด๋“œ ๋ธ”๋ก์„ ์ฑ„ํŒ… ์ŠคํŠธ๋ฆผ์— ์ง์ ‘ ๋ฐฉ์ถœ(ํ…์ŠคํŠธ๋กœ ๋ชจ๋ธ์— ๋„˜๊ธฐ๋ฉด ์นด๋“œ๊ฐ€ ์•ˆ ๋œฌ๋‹ค) return (yield from self._handle_search_youtube({ "query": query, "limit": self._extract_limit(user_prompt, 8), }, ctx)) # ๐Ÿ–ผ๏ธ Pexels / Pixabay: ๋ฏธ๋””์–ด ์†Œ์Šค ์„ ํƒ ๋ฉ”๋‰ด(์˜ˆ: ๋™์˜์ƒ ์ถœ์ฒ˜ ์„ ํƒ) if "pexels" in ans.lower() or "pixabay" in ans.lower(): provider = "pixabay" if "pixabay" in ans.lower() else "pexels" media_type = self._infer_media_type(user_prompt, ans) yield "\n\n" + t("๐Ÿ–ผ๏ธ {provider} {mediaType} ๊ฒ€์ƒ‰์œผ๋กœ '{query}' ์ •์ฐฐ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.", provider=provider, mediaType=media_type, query=query) + "\n\n" return (yield from self._handle_search_media({ "query": query, "media_type": media_type, "provider": provider, "limit": self._extract_limit(user_prompt, 8), }, ctx)) if (four_way_menu and selected_index == 0) or "๋„ค์ด๋ฒ„" in ans: yield "\n\n" + t("๐Ÿ”Ž ๋„ค์ด๋ฒ„ ์›น๊ฒ€์ƒ‰์œผ๋กœ '{query}' ์ •์ฐฐ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.", query=query) + "\n\n" return self.brain.web_search(query, engine="naver") if (four_way_menu and selected_index == 1) or "๋‹ค์Œ" in ans: yield "\n\n" + t("๐Ÿ”Ž ๋‹ค์Œ ์›น๊ฒ€์ƒ‰์œผ๋กœ '{query}' ์ •์ฐฐ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.", query=query) + "\n\n" return self.brain.web_search(query, engine="daum") if (four_way_menu and selected_index == 2) or "๊ตฌ๊ธ€" in ans or any(k in ans for k in ("google", "Google", "GOOGLE")): yield "\n\n" + t("๐Ÿ”Ž ๊ตฌ๊ธ€ ์›น๊ฒ€์ƒ‰์œผ๋กœ '{query}' ์ •์ฐฐ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.", query=query) + "\n\n" return self.brain.web_search(query, engine="google") if (four_way_menu and selected_index == 3) or any(k in ans for k in ("์ด์ง€์Šค", "๋ฏธ๋””์–ด", "์ด๋ฏธ์ง€", "GIF", "gif", "๋™์˜์ƒ", "3D", "3d")): if self._looks_3d(user_prompt): yield "\n\n" + t("๐ŸงŠ ์ด์ง€์Šค 3D ๋ชจ๋ธ๊ฒ€์ƒ‰์œผ๋กœ '{query}' ์ •์ฐฐ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.", query=query) + "\n\n" return (yield from self._handle_search_3d({ "query": query, "limit": self._extract_limit(user_prompt, 8), }, ctx)) media_type = self._infer_media_type(user_prompt, ans) yield "\n\n" + t("๐Ÿ–ผ๏ธ ์ด์ง€์Šค ๋ฏธ๋””์–ด๊ฒ€์ƒ‰์œผ๋กœ '{query}' ์ •์ฐฐ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.", query=query) + "\n\n" return (yield from self._handle_search_media({ "query": query, "media_type": media_type, "provider": "pexels", "limit": self._extract_limit(user_prompt, 8), }, ctx)) return None @staticmethod def _looks_3d(text): t = (text or "").lower() return any(k in t for k in ("3d", "glb", "๋ชจ๋ธ", "์—์…‹", "asset")) @staticmethod def _infer_media_type(prompt, answer=""): p = (prompt or "").lower() if any(k in p for k in ("gif", "์›€์งค", "์งค")): return "gif" if any(k in p for k in ("video", "๋™์˜์ƒ", "์˜์ƒ")): return "video" return "photo" @staticmethod def _extract_limit(text, default=8): m = re.search(r"(\d{1,2})\s*(?:๊ฐœ|์žฅ|๊ฑด|๋งค|results?|items?)", text or "", re.IGNORECASE) if not m: return default try: return max(1, min(20, int(m.group(1)))) except Exception: return default @staticmethod def _clean_search_query(text): q = (text or "").strip() q = re.sub(r"\d{1,2}\s*(?:๊ฐœ|์žฅ|๊ฑด|๋งค)\s*", " ", q) q = re.sub(r"(์ฐพ์•„\s*์ฃผ์„ธ์š”|์ฐพ์•„์ค˜|๊ฒ€์ƒ‰\s*ํ•ด์ฃผ์„ธ์š”|๊ฒ€์ƒ‰ํ•ด์ค˜|๊ตฌํ•ด์ค˜|์ฃผ์„ธ์š”|ํ•ด์ค˜)", " ", q) q = re.sub(r"(์ด๋ฏธ์ง€|์‚ฌ์ง„|๊ทธ๋ฆผ|๋™์˜์ƒ|์˜์ƒ|gif|GIF|์›€์งค|์งค|3D\s*๋ชจ๋ธ|3d\s*๋ชจ๋ธ|๋ชจ๋ธ|์—์…‹)", " ", q) q = re.sub(r"\s+", " ", q).strip() return q or (text or "").strip() or "๊ฒ€์ƒ‰" # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # ๐ŸงŠ search_3d_model (UI ์นด๋“œ ๋ฐฉ์ถœ) โ€” ์ œ๋„ˆ๋ ˆ์ดํ„ฐ, ๊ฒฐ๊ณผ ๋ฌธ์ž์—ด return # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _handle_search_3d(self, args, ctx): # ๋นˆ query๋ฉด ์œ ์ € ๋ฐœํ™”๋กœ ์ž๋™ ๋ณด์ • (์•ฝํ•œ ๋ชจ๋ธ ๋ฐฉ์–ด) query = (str(args.get("query") or "").strip()) or ctx.user_prompt d_limit, d_max = self._tool_default("search_3d_model", 8, 20) try: limit = int(args.get("limit") or 0) except (TypeError, ValueError): limit = 0 if limit <= 0: # ๋ชจ๋ธ์ด limit์„ ๋นผ๋จน์œผ๋ฉด ์œ ์ € ๋ฐœํ™”์˜ ๊ฐœ์ˆ˜("2๊ฐœ" ๋“ฑ)๋กœ ๋ณด์ • (gemma4:12b ๋ฐฉ์–ด) m = re.search(r"(\d{1,2})\s*(?:๊ฐœ|์žฅ|๊ฑด|๋งค)", ctx.user_prompt or "") limit = int(m.group(1)) if m else d_limit limit = max(1, min(d_max, limit)) source = str(args.get("source") or "both").strip().lower() if source not in ("pixabay", "sketchfab", "both"): source = "both" models = self.brain.search_3d_model(query, limit, source=source) if not models: return (f"'{query}' 3D ๋ชจ๋ธ ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†๋‹ค. ๋‹ค๋ฅธ ๊ฒ€์ƒ‰์–ด๋ฅผ ์งง๊ฒŒ ์ œ์•ˆํ•˜๋ผ. " "(URL์ด๋‚˜ ์‚ฌ์ดํŠธ๋ฅผ ์ง€์–ด๋‚ด์ง€ ๋งˆ๋ผ.)") # ๐ŸงŠ imperial-3d ๋ธ”๋ก์„ ์ŠคํŠธ๋ฆผ์— ์ง์ ‘ ๋ฐฉ์ถœ โ†’ ํ—ค์ž„๋‹ฌ์ด ํ”„๋ฆฌ๋ทฐ+๋ทฐ์–ด ์นด๋“œ๋กœ ๋ Œ๋” payload = {"query": query, "models": models} yield f"\n\n```imperial-3d\n{json.dumps(payload, ensure_ascii=False)}\n```\n\n" return (f"3D ๋ชจ๋ธ {len(models)}๊ฐœ๋ฅผ ํ”„๋ฆฌ๋ทฐ+3D ๋ทฐ์–ด ์นด๋“œ๋กœ ํ™”๋ฉด์— ํ‘œ์‹œํ–ˆ๋‹ค. " "๊ฒฐ๊ณผ๋ฅผ ๋‹ค์‹œ ๋‚˜์—ดํ•˜์ง€ ๋ง๊ณ , ํ•œ ์ค„๋กœ ์งง๊ฒŒ๋งŒ ๋งˆ๋ฌด๋ฆฌํ•˜๋ผ.") # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # ๋„๊ตฌ ํ˜ธ์ถœ ์ผ๊ด„ ์‹คํ–‰ (์ œ๋„ˆ๋ ˆ์ดํ„ฐ: UI ๋ธ”๋ก/๋””๋ฒ„๊ทธ ์•ก์…˜์„ yield) # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def run_tool_calls(self, tool_calls, full_content, messages, ctx): ctx.used_tools = True # ๐Ÿ›ก๏ธ [ํ•„์ˆ˜ ๋„๊ตฌ ์ถ”์ ] ์–ด๋–ค ๋„๊ตฌ๊ฐ€ ์‹ค์ œ ํ˜ธ์ถœ๋๋Š”์ง€ ์ด๋ฆ„์„ ๋‚จ๊ธด๋‹ค # (require_tool_call ์žฌ์ด‰ ํŒ์ •์šฉ โ€” used_tools bool ๋งŒ์œผ๋ก  ๋ถ€์กฑ). if not hasattr(ctx, "used_tool_names"): ctx.used_tool_names = set() for _tc in tool_calls: try: ctx.used_tool_names.add(str(_tc["function"]["name"])) except Exception: pass messages.append({"role": "assistant", "content": full_content, "tool_calls": tool_calls}) _any_tool_error = False # ๐Ÿ“Š ์ด๋ฒˆ ๋ฐฐ์น˜์— ๋„๊ตฌ ์—๋Ÿฌ๊ฐ€ ์žˆ์—ˆ๋Š”์ง€(์Šคํ‚ฌ ์˜ค๋ฅ˜์œจ ๊ท€์†์šฉ) _images = [] # ๐Ÿ–ผ๏ธ ToolResult.images โ€” ์ด๋ฏธ์ง€๋ฅผ ๋ฐ›๋Š” ๋‘๋‡Œ(ctx.accept_images)์—๋งŒ ๋„˜๊ธด๋‹ค # ๐ŸŽ–๏ธ [์‹คํ–‰ ๊ด€๋ฌธ] ์ง์—…/์ž„๋ฌด whitelist ๋Š” ๋…ธ์ถœ(์Šคํ‚ค๋งˆ ํ๋ ˆ์ด์…˜)๋ฟ ์•„๋‹ˆ๋ผ **์‹คํ–‰ # ์‹œ์ **์—๋„ ์ง‘ํ–‰ํ•œ๋‹ค. ๋…ธ์ถœ๋งŒ ๋ง‰์œผ๋ฉด ๊ฐ€์งœ ํ…์ŠคํŠธ ๋„๊ตฌํ˜ธ์ถœ ๊ตฌ์ œยท์žฌ์ด‰ ๋“ฑ ์šฐํšŒ # ๊ฒฝ๋กœ๊ฐ€ ๋šซ๋ฆฐ๋‹ค (2026-07-20 ์‹ค์‚ฌ๊ณ : has_tools=False ์ธ๋ฐ ๊ตฌ์ œ ํŒŒ์„œ๊ฐ€ # character_generate ๋ฅผ ์‹คํ–‰ํ•ด ๋ณด๊ณ  ์ž„๋ฌด๊ฐ€ ์ƒ์„ฑ์œผ๋กœ ๋น—๋‚˜๊ฐ). ๋ฒ ์ด์Šค ๊ณต์šฉ # ๋„๊ตฌ(_WHITELIST_BASE_TOOLS)๋Š” ํ†ต๊ณผ โ€” ์ผ์„ ์•„์˜ˆ ๋ชป ํ•˜๊ฒŒ ๋˜๋Š” ๊ฒƒ ๋ฐฉ์ง€. _wl_cfg = getattr(self.brain, "dynamic_config", None) or {} _wl_on = str(_wl_cfg.get("tool_mode") or "all").lower() == "whitelist" _wl_allowed = {str(t) for t in (_wl_cfg.get("allowed_tools") or [])} _wl_base = getattr(self.brain, "_WHITELIST_BASE_TOOLS", None) or { "recall_memory", "count_history", "search_knowledge", "save_knowledge", "check_port", "find_process", "ask_user"} for tc in tool_calls: func_name = tc["function"]["name"] tc_id = tc["id"] if _wl_on and func_name not in _wl_base and func_name not in _wl_allowed: logger.info(f"[{ctx.display_name} Tool] ๐Ÿšซ whitelist ์‹คํ–‰ ์ฐจ๋‹จ: {func_name} " f"(ํ—ˆ์šฉ: {sorted(_wl_allowed) or '์—†์Œ'})") messages.append({ "role": "tool", "tool_call_id": tc_id, "name": func_name, "content": ("๊ฑฐ๋ถ€: ์ด ํ„ด์—์„œ ๊ธˆ์ง€๋œ ๋„๊ตฌ๋‹ค(์ง์—…/์ž„๋ฌด ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ ๋ฐ– โ€” " "์‹คํ–‰๋˜์ง€ ์•Š์•˜๋‹ค). ํ—ˆ์šฉ๋œ ์ˆ˜๋‹จ๋งŒ์œผ๋กœ ์ˆ˜ํ–‰ํ•˜๊ฑฐ๋‚˜, ๋ถˆ๊ฐ€ํ•˜๋ฉด " "'ํ•ด๋‹น ์ž‘์—…์€ ์ด ํŽธ์„ฑ์œผ๋กœ ์ˆ˜ํ–‰ ๋ถˆ๊ฐ€'๋ผ๊ณ  ์ •์งํ•˜๊ฒŒ ๋ณด๊ณ ํ•˜๋ผ."), }) _any_tool_error = True continue result = "Error: Unknown neural tool malfunction." args = {} try: try: args = json.loads(tc["function"]["arguments"]) except Exception: args = {} # ๐Ÿ›ฐ๏ธ ๋„๊ตฌ ๋งˆ์ปค์— ํ•ต์‹ฌ ์ธ์ž ์š”์•ฝ ๋™๋ด‰ โ€” CLI/ํ—ค์ž„๋‹ฌ ์‹ค์‹œ๊ฐ„ ๋„๊ตฌ ํ”ผ๋“œ์šฉ # (ํ˜•์‹: [TOOL_USE: name ยท detail] โ€” ์†Œ๋น„์ธก ์ •๊ทœ์‹์€ [^\]]*๋กœ ๊ด€์šฉ์ , # ํ—ค์ž„๋‹ฌ ์ŠคํŠธ๋ฆฌ๋ฐ ๋ฐฐ์ง€๊ฐ€ ๋งˆ์ปค ๋‚ด๋ถ€๋ฅผ ๊ทธ๋Œ€๋กœ ๋ณด์—ฌ์ฃผ๋ฏ€๋กœ ๊ฐ€๋… ๊ตฌ๋ถ„์ž ์‚ฌ์šฉ) detail = _tool_marker_detail(args) yield f"[TOOL_USE: {func_name} ยท {detail}]" if detail else f"[TOOL_USE: {func_name}]" logger.info(f"[{ctx.display_name} Tool] {func_name}({args})") if func_name == "ask_user": result = yield from self._handle_ask_user(args, ctx) elif func_name == "search_media": result = yield from self._handle_search_media(args, ctx) elif func_name == "search_3d_model": result = yield from self._handle_search_3d(args, ctx) elif func_name == "search_youtube": result = yield from self._handle_search_youtube(args, ctx) elif func_name == "character_generate": result = yield from self._handle_character_generate(args, ctx) else: result = self.dispatch(func_name, args, ctx) except Exception as tool_err: result = f"Error: tool execution failed: {tool_err}" logger.error(f"Neural Tool Error ({func_name}): {tool_err}") # ๐Ÿฉบ ๋„๊ตฌ ์‹คํŒจ๋ฅผ Vault ๊ฒฝํ—˜ Inbox(AI-Health)์— ์ถ•์ ํ•œ๋‹ค. # 2026-07-17 ์žฌ๊ฑด: โ‘ ctx.prompt(์กด์žฌํ•˜์ง€ ์•Š๋Š” ํ•„๋“œ) AttributeError ๋กœ ๊ธฐ๋ก์ด # ํ•ญ์ƒ ์กฐ์šฉํžˆ ์ƒ๋žต๋˜๋˜ ์‚ฌ๋ง ์ƒํƒœ ์ˆ˜๋ฆฌ(โ†’ ctx.user_prompt) โ‘กvault ์ง์—… ํ„ด # ํ•œ์ • ๊ฒŒ์ดํŠธ ์ œ๊ฑฐ โ€” '๊ธฐ๋ก์— ์—†์œผ๋ฉด ์ •์ƒ'์ด ์„ฑ๋ฆฝํ•˜๋ ค๋ฉด ์ด ๋ฐ๋ชฌ์˜ ๋ชจ๋“  # ์‹คํŒจ๋ฅผ ์žก์•„์•ผ ํ•œ๋‹ค โ‘ขํ•œ๊ตญ์–ด ์‹คํŒจ ์‹œ๊ทธ๋‹ˆ์ฒ˜('์‹คํŒจ:'ยท'๊ฑฐ๋ถ€:'ยท'์˜ค๋ฅ˜') ํฌํ•จ. _head = str(result)[:80] _is_failure = ( _head.lower().startswith("error") or _head.startswith(("โŒ", "์˜ค๋ฅ˜", "๊ฑฐ๋ถ€:")) or "์‹คํŒจ:" in _head or "์‹คํŒจํ–ˆ" in _head ) if _is_failure: _any_tool_error = True try: cfg = getattr(self.brain, "dynamic_config", None) or {} from obsidian_health import record_tool_failure record_tool_failure( func_name, result, prompt=ctx.user_prompt, guardian=ctx.display_name, job=cfg.get("job", ""), ) except Exception as health_err: logger.warning("[Obsidian Health] ์‹คํŒจ ๊ฒฝํ—˜ ๊ธฐ๋ก ์‹คํŒจ: %s", health_err) messages.append({ "role": "tool", "tool_call_id": tc_id, "name": func_name, "content": str(result), }) if getattr(result, "images", None): _images += [(func_name, img) for img in result.images] # ๐Ÿ—‚๏ธ obsidian_write ์‹ค์ œ ํ˜ธ์ถœ ์ถ”์  โ€” ์žฌ์ด‰(nudge) ์‹œ ์ค‘๋ณต ์ž‘์„ฑ ๋ฐฉ์ง€์šฉ if func_name == "obsidian_write" and "Error" not in str(result): ctx.obsidian_written = True # ๐Ÿž ๋„๊ตฌ ์•ก์…˜ ๋กœ๊ทธ (Debug ON์ด๋ฉด ์ฑ„ํŒ…์—๋„, OFF๋ฉด ์„œ๋ฒ„ ๋กœ๊ทธ๋งŒ) if self._summarize: line = self._summarize(ctx.display_name, func_name, result) else: line = f"[{ctx.display_name} Action: {func_name}] {str(result)[:200]}" if self._is_debug(): yield f"\n\nโšก {line}\n\n" else: logger.info(f"โšก {line}") # ๐Ÿ•ธ๏ธ hugin ์ •์ฐฐ ๊ฒฐ๊ณผ๋Š” web_recon ๋ฉ”๋ชจ๋ฆฌ์— ์ €์žฅ if func_name in ("hugin_navigate", "hugin_type") and "Error" not in str(result): try: self.brain._store_web_recon(func_name, args, result) except Exception as mem_err: logger.warning(f"Failed to store web recon memory: {mem_err}") # ๐Ÿ–ผ๏ธ ๋„๊ตฌ ๊ฒฐ๊ณผ ์ด๋ฏธ์ง€ โ€” tool ๋ฉ”์‹œ์ง€๋Š” ๊ธ€์ž๋งŒ ๋‹ด์„ ์ˆ˜ ์žˆ์–ด ๋ฐฐ์น˜ ๋’ค ์‚ฌ์šฉ์ž ๋ฉ”์‹œ์ง€๋กœ ๋ถ™์ธ๋‹ค # (tool_calls ์งํ›„์—” tool ๋ฉ”์‹œ์ง€๋“ค์ด ๋Š๊น€ ์—†์ด ์™€์•ผ ํ•˜๋ฏ€๋กœ ๋ฐ˜๋“œ์‹œ ๋ฃจํ”„ ๋ฐ–์—์„œ). if _images and getattr(ctx, "accept_images", False): parts = [{"type": "text", "text": "[๋„๊ตฌ ๊ฒฐ๊ณผ ์ด๋ฏธ์ง€ โ€” " + ", ".join(dict.fromkeys(n for n, _ in _images)) + " ๊ฐ€ ๋Œ๋ ค์ค€ ํ™”๋ฉด์ด๋‹ค. ์ง์ ‘ ๋ณด๊ณ  ํŒ๋‹จํ•˜๋ผ.]"}] parts += [{"type": "image_url", "image_url": {"url": f"data:{img['mimeType']};base64,{img['data']}"}} for _, img in _images[:8]] messages.append({"role": "user", "content": parts}) # ๐Ÿ“Š [์Šคํ‚ฌ ์˜ค๋ฅ˜์œจ] ์ด๋ฒˆ ํ„ด์— ์ฃผ์ž…๋œ ์Šคํ‚ฌ์— ๋„๊ตฌ ์„ฑ๊ณต/์‹คํŒจ๋ฅผ ๊ท€์†(์ƒ๊ด€ ์‹ ํ˜ธ). # ์™„๋ฒฝํ•œ ์ธ๊ณผ๋Š” ์•„๋‹ˆ์ง€๋งŒ ๋ˆ„์ ๋˜๋ฉด '์ž์ฃผ ๊นจ์ง€๋Š” ์Šคํ‚ฌ'์„ ๋“œ๋Ÿฌ๋‚ธ๋‹ค. try: _skills = getattr(self.brain, "_last_equipped_skills", None) if _skills: self.brain.record_skill_outcome(_skills, ok=not _any_tool_error) except Exception as _sk_e: logger.debug("[Skill Outcome] ๊ธฐ๋ก ์ƒ๋žต: %s", _sk_e) # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # tool_calls๊ฐ€ ์—†์„ ๋•Œ(๋ชจ๋ธ์ด ํ…์ŠคํŠธ๋งŒ ์ถœ๋ ฅ) ๊ตฌ์ œ ์ฒ˜๋ฆฌ # ๋ฐ˜ํ™˜: "continue"(๋ฃจํ”„ ๊ณ„์†) | "break"(ํ„ด ์ข…๋ฃŒ) # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def handle_no_tool_calls(self, full_content, messages, ctx): # ๐Ÿ”ฑ Empty-Final Guard: ๋„๊ตฌ๋งŒ ์“ฐ๊ณ  ์ตœ์ข… ๋‹ต๋ณ€์ด ๋น„๋ฉด ํ•œ ๋ฒˆ ์žฌ์ด‰ if ctx.used_tools and not full_content.strip() and not ctx.nudged_final: ctx.nudged_final = True messages.append({ "role": "user", "content": "๋ฐฉ๊ธˆ ์‹คํ–‰ํ•œ ๋„๊ตฌ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ, ์›๋ž˜ ์งˆ๋ฌธ์— ๋Œ€ํ•œ ์ตœ์ข… ๋‹ต๋ณ€์„ " "ํ•œ๊ตญ์–ด๋กœ ๊ฐ„๊ฒฐํ•˜๊ฒŒ ์ž‘์„ฑํ•˜๋ผ. ๋„๊ตฌ๋ฅผ ๋‹ค์‹œ ํ˜ธ์ถœํ•˜์ง€ ๋งˆ๋ผ.", }) return "continue" # ๐Ÿ›Ÿ Fake-Ask / ๐Ÿฝ Markdown-Ask Rescue if ctx.interactive and not ctx.ask_rescued: _fake = self._extract_fake_ask_payload(full_content) # ask๋ฅผ ์‹œ๋„ํ•œ ํ„ด์—์„œ๋งŒ ๋ณธ๋ฌธ ๋ชฉ๋ก(โ‘ โ‘กโ‘ข ๋“ฑ)์„ ์นด๋“œ๋กœ ๋ณ€ํ™˜ (์˜ค์ž‘๋™ ๋ฐฉ์ง€) if not _fake and ctx.ask_attempted: _fake = self._extract_ask_from_markdown(full_content) if _fake: logger.info(f"[Markdown-Ask Rescue] ๋ณธ๋ฌธ ๋ชฉ๋ก โ†’ ask_user ์นด๋“œ ๋ณ€ํ™˜ " f"(์˜ต์…˜ {len(_fake.get('options', []))}๊ฐœ)") if _fake: ctx.ask_rescued = True # ๐Ÿ”‡ ์นด๋“œ๋กœ ๋ณ€ํ™˜ ์„ฑ๊ณต โ†’ ๋ณด๋ฅ˜ํ–ˆ๋˜ ์›๋ณธ ํ…์ŠคํŠธ๋Š” ๋ฒ„๋ฆฐ๋‹ค(2์ค‘ ํ‘œ์‹œ ๋ฐฉ์ง€) ctx.suppress_stream = False logger.info("[Fake-Ask Rescue] ๋ณธ๋ฌธ์„ ์‹ค์ œ ask_user ํ”Œ๋กœ์šฐ๋กœ ๋ณ€ํ™˜") _ask = self.brain._begin_ask_user({ "question": _fake.get("question", ""), "options": _fake.get("options") or [], "timeout_seconds": _fake.get("timeout_seconds", 120), }) yield f"\n\n```imperial-ask\n{json.dumps(_ask, ensure_ascii=False)}\n```\n\n" _ans = self.brain._wait_ask_user(_ask["ask_id"], _ask["timeout_seconds"]) if _ans is None: yield t("โฑ๏ธ ์„ ํƒ ๋Œ€๊ธฐ ์‹œ๊ฐ„({sec}์ดˆ)์ด ์ดˆ๊ณผ๋˜์–ด ์งˆ๋ฌธ์ด ์ž๋™ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", sec=_ask['timeout_seconds']) + "\n\n" return "break" if _ans == "__CANCELLED__": yield t("๐Ÿšซ ์งˆ๋ฌธ์ด ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค (์œ ์ €๊ฐ€ ๋‹ค๋ฅธ ์ž‘์—…์œผ๋กœ ์ „ํ™˜).") + "\n\n" return "break" messages.append({"role": "assistant", "content": full_content}) messages.append({ "role": "user", "content": (f"(์„ ํƒ์ง€ ์งˆ๋ฌธ์— ๋Œ€ํ•œ ์œ ์ €์˜ ์„ ํƒ: {_ans}) ์ด ์„ ํƒ์— ๋งž์ถฐ ์ด์–ด์„œ " "๋‹ตํ•˜๋ผ. ๊ฐ™์€ ์งˆ๋ฌธ์„ ๋ฐ˜๋ณตํ•˜๊ฑฐ๋‚˜ ์„ ํƒ์ง€ JSON์„ ๋‹ค์‹œ ์ถœ๋ ฅํ•˜์ง€ ๋งˆ๋ผ."), }) return "continue" # ๐Ÿ”‡ ์นด๋“œ๋กœ ๋ณ€ํ™˜๋˜์ง€ ์•Š์•˜๋Š”๋ฐ ์ŠคํŠธ๋ฆผ์„ ๋ณด๋ฅ˜ํ–ˆ๋‹ค๋ฉด, ๋ณด๋ฅ˜ํ•œ ํ…์ŠคํŠธ๋ฅผ ์ง€๊ธˆ ํ•œ ๋ฒˆ ์ถœ๋ ฅ(flush) if ctx.suppress_stream and full_content.strip(): yield full_content ctx.suppress_stream = False return "break" # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # ๐Ÿ—‚๏ธ ์˜ต์‹œ๋””์–ธ ์•ฝ์†-๋ฏธ์ดํ–‰ ์žฌ์ด‰: ๋ชจ๋ธ์ด "์ƒ์„ฑ/์ž‘์„ฑํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค"๋ผ๊ณ  ๋ง๋งŒ ํ•˜๊ณ  # obsidian_write๋ฅผ ํ˜ธ์ถœํ•˜์ง€ ์•Š์œผ๋ฉด, ์‹ค์ œ ํ˜ธ์ถœํ•˜๋„๋ก ํ•œ ๋ฒˆ ๋” ์žฌ์ด‰ํ•œ๋‹ค. # ๋ฐ˜ํ™˜: "continue"(์žฌ์ด‰ ํ›„ ๋ฃจํ”„ ๊ณ„์†) | "break"(์žฌ์ด‰ ๋ถˆ๊ฐ€/์†Œ์ง„ โ†’ ์ข…๋ฃŒ) # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ _PROMISE_PHRASES = ("ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค", "ํ• ๊ฒŒ์š”", "ํ• ๊นŒ์š”", "๋งŒ๋“ค๊ฒ ", "์ƒ์„ฑํ•˜๊ฒ ", "์ž‘์„ฑํ•˜๊ฒ ", "์ถ”๊ฐ€ํ•˜๊ฒ ", "์ €์žฅํ•˜๊ฒ ", "์ •๋ฆฌํ•˜๊ฒ ", "๊ธฐ๋กํ•˜๊ฒ ", "์ƒ์„ฑํ• ", "์ž‘์„ฑํ• ", "๋งŒ๋“ค์–ด ๋“œ๋ฆฌ๊ฒ ", # ๐Ÿ”ฑ '์˜ฌ๋ ค์ค˜' ๋„๋ฉ”์ธ ์–ดํœ˜ ์ถ”๊ฐ€ โ€” "์˜ต์‹œ๋””์–ธ์— ~ ์˜ฌ๋ ค๋“œ๋ฆฌ๊ฒ ์Šต๋‹ˆ๋‹ค" ๋“ฑ "์˜ฌ๋ ค๋“œ๋ฆฌ", "์˜ฌ๋ ค ๋“œ๋ฆฌ", "์˜ฌ๋ฆฌ๊ฒ ", "์˜ฌ๋ ค๋‘˜", "์˜ฌ๋ ค๋‘๊ฒ ", "์—…๋กœ๋“œ", "๋ฐ˜์˜ํ•˜๊ฒ ") def nudge_required_tool(self, full_content, messages, ctx, required): """๐Ÿ›ก๏ธ ์ง์—… ์„ ์–ธ ํ•„์ˆ˜ ๋„๊ตฌ(require_tool_call) ๋ฏธํ˜ธ์ถœ ์žฌ์ด‰. ์ง์—… frontmatter ๊ฐ€ require_tool_call: [๋„๊ตฌ...] ๋ฅผ ์„ ์–ธํ•˜๋ฉด, ๊ทธ ์ง์—… ํ„ด์—์„œ ์„ ์–ธ ๋„๊ตฌ๋ฅผ ํ•˜๋‚˜๋„ ์‹ค์ œ ํ˜ธ์ถœํ•˜์ง€ ์•Š๊ณ  ๋ง๋กœ๋งŒ ๋‹ต์„ ๋๋‚ด๋ ค ํ•  ๋•Œ ํ•œ ๋ฒˆ ์žฌ์ด‰ํ•œ๋‹ค. ์‹ค์‚ฌ๊ณ (2026-07-18): ๋กœ๋“œ๋งต ์„œ๊ธฐ๊ฐ€ ํžˆ์Šคํ† ๋ฆฌ์˜ ์˜› ๋กœ๋“œ๋งต ๋‹ต๋ณ€์„ ๋ฒ ๋ผ๊ณ  ๋„๊ตฌ๋ฅผ ๊ฑด๋„ˆ๋œ€ โ€” rule ๋ฌธ๊ตฌ ๊ฐ•ํ™” 2ํšŒ๋กœ๋„ ๋ชป ์žก์•„ ๊ธฐ๊ณ„ ๊ฐ€๋“œ๋กœ ๊ฒฉ์ƒ. ๋ฐ˜ํ™˜: "continue"(์žฌ์ด‰ ํ›„ ๋ฃจํ”„ ๊ณ„์†) | "break"(ํ•ด๋‹น ์—†์Œ/์†Œ์ง„). """ req = [str(t).strip() for t in (required or []) if str(t).strip()] if not req: return "break" called = getattr(ctx, "used_tool_names", set()) or set() if any(t in called for t in req): return "break" if getattr(ctx, "required_tool_nudges", 0) >= 2: return "break" if not (full_content or "").strip(): return "break" ctx.required_tool_nudges = getattr(ctx, "required_tool_nudges", 0) + 1 names = ", ".join(req) messages.append({"role": "assistant", "content": full_content}) messages.append({ "role": "user", "content": (f"๋ฐฉ๊ธˆ ๋‹ต๋ณ€์€ ๋ฌดํšจ๋‹ค โ€” ํ•„์ˆ˜ ๋„๊ตฌ({names})๋ฅผ ํ˜ธ์ถœํ•˜์ง€ ์•Š์•˜๋‹ค. " f"์ด์ „ ๋Œ€ํ™” ๋‚ด์šฉ์„ ๋ฒ ๋ผ์ง€ ๋ง๊ณ , ์ง€๊ธˆ ์ฆ‰์‹œ {names} ๋„๊ตฌ๋ฅผ ์‹ค์ œ๋กœ " "ํ˜ธ์ถœํ•œ ๋’ค ๊ทธ ๊ฒฐ๊ณผ๋งŒ์œผ๋กœ ๋‹ค์‹œ ๋‹ตํ•˜๋ผ."), }) logger.info( f"๐Ÿ›ก๏ธ [Required-Tool Nudge] ํ•„์ˆ˜ ๋„๊ตฌ ๋ฏธํ˜ธ์ถœ({names}) โ€” ๊ฐ•์ œ ์žฌ์ด‰ " f"({ctx.required_tool_nudges}/2)" ) return "continue" def nudge_obsidian_write(self, full_content, messages, ctx): if not getattr(ctx, "expect_obsidian_write", False): return "break" if ctx.promise_nudges >= 2: return "break" # ๐Ÿ—‚๏ธ ์ด๋ฏธ ์ด ํ„ด์— obsidian_write ๋ฅผ ์‹ค์ œ ํ˜ธ์ถœํ–ˆ์œผ๋ฉด ์žฌ์ด‰ ๋ถˆํ•„์š”(์ค‘๋ณต ์ž‘์„ฑ ๋ฐฉ์ง€). if getattr(ctx, "obsidian_written", False): return "break" text = (full_content or "") has_promise = any(ph in text for ph in self._PROMISE_PHRASES) # ๐Ÿ”ฑ [Fix] ์•ฝ์† ์–ด๊ตฌ๊ฐ€ ์—†์–ด๋„, ์ด๋ฒˆ ํ„ด์— ๋‹ค๋ฅธ ๋„๊ตฌ(๊ฒ€์ƒ‰ ๋“ฑ)๋ฅผ ์“ฐ๊ณ  ๊ธ€์€ ์•ˆ ์“ด ๊ฒฝ์šฐ์—” # ์žฌ์ด‰ํ•œ๋‹ค. "๋‚ ์”จ ๊ฒ€์ƒ‰ โ†’ ๋ณด๊ณ ๋งŒ ํ•˜๊ณ  ์˜ต์‹œ๋””์–ธ์— ์•ˆ ์˜ฌ๋ฆผ" ํŒจํ„ด์„ ์žก๊ธฐ ์œ„ํ•จ. # (๋„๊ตฌ๋„ ์•ˆ ์“ฐ๊ณ  ์•ฝ์†๋„ ์—†๋Š” ์ˆœ์ˆ˜ ๋Œ€ํ™”๋Š” ์žฌ์ด‰ํ•˜์ง€ ์•Š์Œ โ†’ ์˜ค์ž‘๋™ ๋ฐฉ์ง€.) if not has_promise and not getattr(ctx, "used_tools", False): return "break" ctx.promise_nudges += 1 messages.append({"role": "assistant", "content": full_content}) messages.append({ "role": "user", "content": ("๋ง๋กœ๋งŒ ํ•˜์ง€ ๋ง๊ณ  ์ง€๊ธˆ ์ฆ‰์‹œ obsidian_write ๋„๊ตฌ๋ฅผ ์‹ค์ œ๋กœ ํ˜ธ์ถœํ•˜๋ผ. " "์—ฌ๋Ÿฌ ๋…ธํŠธ๊ฐ€ ํ•„์š”ํ•˜๋ฉด obsidian_write๋ฅผ ๋…ธํŠธ 1๊ฐœ๋‹น ํ•œ ๋ฒˆ์”ฉ ์—ฌ๋Ÿฌ ๋ฒˆ ํ˜ธ์ถœํ•˜๋ผ. " "๊ฐ content์—๋Š” ๋„ค๊ฐ€ ์ง์ ‘ ์ž‘์„ฑํ•œ ์ „์ฒด ๋งˆํฌ๋‹ค์šด(# ์ œ๋ชฉ, [[๋ฐฑ๋งํฌ]], #ํƒœ๊ทธ)์„ ๋„ฃ์–ด๋ผ. " "๋„๊ตฌ๋ฅผ ํ˜ธ์ถœํ•˜์ง€ ์•Š์œผ๋ฉด ์•„๋ฌด๊ฒƒ๋„ ๋งŒ๋“ค์–ด์ง€์ง€ ์•Š๋Š”๋‹ค. ํ˜ธ์ถœ ์ „์—๋Š” '์™„๋ฃŒ'๋ผ๊ณ  ๋งํ•˜์ง€ ๋งˆ๋ผ."), }) logger.info(f"๐Ÿ—‚๏ธ [Obsidian Nudge] ์•ฝ์†-๋ฏธ์ดํ–‰ ๊ฐ์ง€ โ€” obsidian_write ๊ฐ•์ œ ์žฌ์ด‰ ({ctx.promise_nudges}/2)") return "continue"