Spaces:
Running
Running
Download shadow_brain_core/brain/tool_runtime.py from taemin1980/shadowbrain: direct link, hf CLI and curl.
- Browser
- Download file 111 kB
-
https://huggingface.co/spaces/taemin1980/shadowbrain/resolve/main/shadow_brain_core/brain/tool_runtime.py
- Command line
-
hf download hf://spaces/taemin1980/shadowbrain/shadow_brain_core/brain/tool_runtime.py
-
curl -L -o tool_runtime.py https://huggingface.co/spaces/taemin1980/shadowbrain/resolve/main/shadow_brain_core/brain/tool_runtime.py
111 kB
| """🛡️ 제국 도구 런타임 (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/<name>.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.<tool>의 (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.<intent>.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_' 제거 → '<server>_<tool>' | |
| 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 카드 복원 파서 (순수 함수) | |
| # ────────────────────────────────────────────── | |
| 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 | |
| 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 | |
| def _looks_3d(text): | |
| t = (text or "").lower() | |
| return any(k in t for k in ("3d", "glb", "모델", "에셋", "asset")) | |
| 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" | |
| 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 | |
| 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" | |