"""๐Ÿ›ก๏ธ ์ œ๊ตญ ๋„๊ตฌ ๋Ÿฐํƒ€์ž„ (Shadow Brain Tool Runtime) โ€” Phase 1: ๋””์ŠคํŒจ์ฒ˜ ๋‹จ์ผํ™”. 3๊ฐœ OpenAI์‹ ๋Œ€ํ™” ๊ฒฝ๋กœ(`_think_copilot` / `_think_atlas` / `_think_openrouter`)๊ฐ€ ๊ณต์œ ํ•˜๋Š” ๋‹จ์ผ ๋„๊ตฌ ์‹คํ–‰ ๋Ÿฐํƒ€์ž„. ์ธ์ž ๊ฒ€์ฆยท์ž๋™ ๋ณด์ •ยทask ๊ฐ€๋“œ/์„œํ‚ท๋ธŒ๋ ˆ์ด์ปคยท ํ…์ŠคํŠธโ†’์นด๋“œ ์ˆ˜ํ™•์„ ํ•œ ๊ณณ์— ๋ชจ์•„, ๋ณต๋ถ™ ๋””์ŠคํŒจ์ฒ˜์˜ ๋“œ๋ฆฌํ”„ํŠธ ๋ฒ„๊ทธ๋ฅผ ์ œ๊ฑฐํ•œ๋‹ค. ์„ค๊ณ„ ์›์น™: - ์ˆœํ™˜ import ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด `core`๋ฅผ import ํ•˜์ง€ ์•Š๋Š”๋‹ค. ํ•„์š”ํ•œ ํ—ฌํผ(_is_debug, _summarize)๋Š” ์ƒ์„ฑ ์‹œ ์ฃผ์ž…๋ฐ›๊ณ , ๋„๊ตฌ ํ•ธ๋“ค๋Ÿฌ๋Š” brain ์ธ์Šคํ„ด์Šค ๋ฉ”์„œ๋“œ๋กœ ์œ„์ž„ํ•œ๋‹ค. - ask_user์ฒ˜๋Ÿผ UI ๋ธ”๋ก์„ ํ˜๋ ค์•ผ ํ•˜๋Š” ๋„๊ตฌ๋Š” ์ œ๋„ˆ๋ ˆ์ดํ„ฐ๋กœ ์ฒ˜๋ฆฌํ•˜๊ณ (`yield from`), ๋น„-yield ๋„๊ตฌ๋Š” `dispatch()` ํ•œ ๊ณณ์—์„œ ์ˆœ์ˆ˜ ํ•จ์ˆ˜๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค. """ import json import logging import re logger = logging.getLogger("JarvisBrain") # ์›ํ˜• ์ˆซ์ž(โ‘ ~โ‘ณ) โ€” ๋ชจ๋ธ์ด ์˜ต์…˜ ๋ฒˆํ˜ธ๋กœ ์ž์ฃผ ์“ด๋‹ค (ํ…์ŠคํŠธโ†’์นด๋“œ ํŒŒ์„œ์šฉ) _CIRCLED_NUM = "โ‘ โ‘กโ‘ขโ‘ฃโ‘คโ‘ฅโ‘ฆโ‘งโ‘จโ‘ฉโ‘ชโ‘ซโ‘ฌโ‘ญโ‘ฎโ‘ฏโ‘ฐโ‘ฑโ‘ฒโ‘ณ" 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) # ์ƒˆ ๋„๊ตฌ๋Š” ์ด ๋ฆฌ์ŠคํŠธ์—๋งŒ ์ถ”๊ฐ€ํ•˜๋ฉด ์Šคํ‚ค๋งˆ/ํ•ธ๋“ค๋Ÿฌ๊ฐ€ ๋™์‹œ์— ๋“ฑ๋ก๋œ๋‹ค โ†’ ๋“œ๋ฆฌํ”„ํŠธ ๋ถˆ๊ฐ€. # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ NATIVE_TOOLS = [ { "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("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")), }, ] # ๐Ÿ—‚๏ธ ์˜ต์‹œ๋””์–ธ ์ง€์‹ ๋ ˆ์ด์–ด ๋ฐ 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}") # ๐ŸงŠ yielding ๋„๊ตฌ: dispatch๊ฐ€ ์•„๋‹ˆ๋ผ run_tool_calls๊ฐ€ UI ๋ธ”๋ก์„ ์ŠคํŠธ๋ฆผ์— ์ง์ ‘ ๋ฐฉ์ถœ (handler=None ํ—ˆ์šฉ) _YIELDING_TOOLS = {"ask_user", "search_media", "search_3d_model", "search_youtube"} # ๐Ÿงน ์ƒํ™ฉํ˜• ๋„๊ตฌ: ํ•ด๋‹น ์˜๋„์ผ ๋•Œ๋งŒ ๋…ธ์ถœ (๊ทธ ์™ธ์—” ๋ชฉ๋ก์„ ์ž‘๊ฒŒ ์œ ์ง€ โ†’ ์•ฝํ•œ ๋ชจ๋ธ ์ •ํ™•๋„โ†‘) _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 _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 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 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_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 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 f"โฑ๏ธ ์„ ํƒ ๋Œ€๊ธฐ ์‹œ๊ฐ„({_ask['timeout_seconds']}์ดˆ)์ด ์ดˆ๊ณผ๋˜์–ด ์งˆ๋ฌธ์ด ์ž๋™ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\n" return ("TIMEOUT: ์œ ์ €๊ฐ€ ์ œํ•œ ์‹œ๊ฐ„ ๋‚ด์— ์„ ํƒํ•˜์ง€ ์•Š์•„ ์งˆ๋ฌธ์ด ์ทจ์†Œ๋˜์—ˆ๋‹ค. " "๋ณด์ˆ˜์ ์ธ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๋งˆ๋ฌด๋ฆฌํ•˜๊ฑฐ๋‚˜, ๊ฒฐ์ •์ด ํ•„์ˆ˜๋ผ๋ฉด ๋‹ต๋ณ€ ๋ง๋ฏธ์— ์งˆ๋ฌธ์„ ๋‚จ๊ธฐ๊ณ  ํ„ด์„ ๋๋‚ด๋ผ.") if _ans == "__CANCELLED__": yield "๐Ÿšซ ์งˆ๋ฌธ์ด ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค (์œ ์ €๊ฐ€ ๋‹ค๋ฅธ ์ž‘์—…์œผ๋กœ ์ „ํ™˜).\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 f"\n\n๐ŸŽฅ ์œ ํŠœ๋ธŒ ๊ฒ€์ƒ‰์œผ๋กœ '{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 f"\n\n๐Ÿ–ผ๏ธ {provider} {media_type} ๊ฒ€์ƒ‰์œผ๋กœ '{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 f"\n\n๐Ÿ”Ž ๋„ค์ด๋ฒ„ ์›น๊ฒ€์ƒ‰์œผ๋กœ '{query}' ์ •์ฐฐ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.\n\n" return self.brain.web_search(query, engine="naver") if (four_way_menu and selected_index == 1) or "๋‹ค์Œ" in ans: yield f"\n\n๐Ÿ”Ž ๋‹ค์Œ ์›น๊ฒ€์ƒ‰์œผ๋กœ '{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 f"\n\n๐Ÿ”Ž ๊ตฌ๊ธ€ ์›น๊ฒ€์ƒ‰์œผ๋กœ '{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 f"\n\n๐ŸงŠ ์ด์ง€์Šค 3D ๋ชจ๋ธ๊ฒ€์ƒ‰์œผ๋กœ '{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 f"\n\n๐Ÿ–ผ๏ธ ์ด์ง€์Šค ๋ฏธ๋””์–ด๊ฒ€์ƒ‰์œผ๋กœ '{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 messages.append({"role": "assistant", "content": full_content, "tool_calls": tool_calls}) for tc in tool_calls: func_name = tc["function"]["name"] yield f"[TOOL_USE: {func_name}]" tc_id = tc["id"] result = "Error: Unknown neural tool malfunction." args = {} try: try: args = json.loads(tc["function"]["arguments"]) except Exception: args = {} 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) 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์— ์ถ•์ ํ•œ๋‹ค. if "error" in str(result).lower(): try: cfg = getattr(self.brain, "dynamic_config", None) or {} if str(cfg.get("job_source") or "").lower() == "vault": from obsidian_health import record_tool_failure record_tool_failure( func_name, result, prompt=ctx.prompt, guardian=ctx.display_name, job=cfg.get("job", ""), ) except Exception as health_err: logger.debug("[Obsidian Health] ์‹คํŒจ ๊ฒฝํ—˜ ๊ธฐ๋ก ์ƒ๋žต: %s", health_err) messages.append({ "role": "tool", "tool_call_id": tc_id, "name": func_name, "content": str(result), }) # ๐Ÿ—‚๏ธ 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_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 f"โฑ๏ธ ์„ ํƒ ๋Œ€๊ธฐ ์‹œ๊ฐ„({_ask['timeout_seconds']}์ดˆ)์ด ์ดˆ๊ณผ๋˜์–ด ์งˆ๋ฌธ์ด ์ž๋™ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\n" return "break" if _ans == "__CANCELLED__": yield "๐Ÿšซ ์งˆ๋ฌธ์ด ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค (์œ ์ €๊ฐ€ ๋‹ค๋ฅธ ์ž‘์—…์œผ๋กœ ์ „ํ™˜).\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_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"