Files
kamidso-back/kamidso/engine/app/openapi/api_v1/subagent/react_tools.py
T
javadandCursor edbaa2acec Add native Didar connectors so chats can query CRM data reliably.
Connector calls, semantic rules, and DuckDB memory keep long runs stable, and stream retry stops chats from stalling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 19:26:47 +03:30

1143 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ReAct tool factory for the react-agent link.
Builds the set of ReAct tools that are bound to a per-(sub-)agent
``react_state`` dict. The main agent and every sub-agent each call
``make_react_tools`` once, capturing their own ``react_state`` — this is the
physical prerequisite for per-sub-agent state isolation (see design spec
§4.5 / plan stage 1).
These tool bodies were relocated verbatim from ``agentic_data_api.py``; only
their dependency sources changed (closure free variables -> factory params or
module-level imports). Behavior is identical, so existing conversations
regress identically.
Returns 8 tools::
load_skill / load_tools / knowledge_retrieve / sql_query /
code_interpreter / shell_interpreter / execute_skill_script_file /
html_interpreter
``todowrite`` is intentionally NOT here — it shares ``_todo_list`` with the
SSE main loop and stays in ``_react_agent_stream``. The module-level skill
tools (``execute_skill_script`` / ``get_skill_resource``) are imported
directly by callers, not rebuilt here.
"""
import json
import logging
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
from kamidso.engine.core._private.config import Config
from kamidso.engine.core.agent.resource.base import AgentResource, ResourceType
from kamidso.engine.core.agent.resource.manage import get_resource_manager
from kamidso.engine.core.agent.resource.tool.base import tool
from kamidso.engine.core.configs.model_config import SKILLS_DIR
CFG = Config()
logger = logging.getLogger(__name__)
DEFAULT_SKILLS_DIR = SKILLS_DIR
AUTO_DATA_MARKER_PATTERN = re.compile(
r"###([A-Z0-9_]+)_START###\s*(.*?)\s*###\1_END###", re.DOTALL
)
def _extract_auto_data_markers(text: str) -> tuple[str, Dict[str, str]]:
"""Extract generic marker blocks from script output text.
Marker format:
###KEY_START###...###KEY_END###
"""
if not text or "###" not in text:
return text, {}
extracted: Dict[str, str] = {}
def _replace(match: re.Match) -> str:
key = match.group(1)
value = match.group(2).strip()
if value:
extracted[key] = value
return ""
cleaned = AUTO_DATA_MARKER_PATTERN.sub(_replace, text)
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
return cleaned, extracted
def _try_repair_truncated_code(raw_code: str) -> Optional[str]:
"""Attempt to fix code that was truncated by the LLM's token limit.
Common symptoms: unterminated string literals, unclosed brackets/parens.
Strategy:
1. Remove the last (likely incomplete) logical line.
2. Close any remaining open brackets / parentheses.
3. Re-compile. If it passes, return the repaired code.
Returns None if repair is not possible.
"""
lines = raw_code.split("\n")
# Try progressively removing trailing lines (up to 10) to find a
# clean cut-off point.
for trim in range(1, min(11, len(lines))):
candidate_lines = lines[: len(lines) - trim]
if not candidate_lines:
continue
candidate = "\n".join(candidate_lines)
# Strip any trailing incomplete string by trying to tokenize
# and removing broken tail tokens.
# Close unmatched brackets/parens/braces
open_chars = {"(": ")", "[": "]", "{": "}"}
close_chars = set(open_chars.values())
stack: list = []
for ch in candidate:
if ch in open_chars:
stack.append(open_chars[ch])
elif ch in close_chars:
if stack and stack[-1] == ch:
stack.pop()
# Append closing chars in reverse order
if stack:
candidate += "\n" + "".join(reversed(stack))
try:
compile(candidate, "<repair>", "exec")
return candidate
except SyntaxError:
continue
return None
def make_react_tools(
react_state: dict,
*,
database_connector: Any = None,
knowledge_resources: Any = None,
) -> Dict[str, Any]:
"""Build a set of ReAct tools bound to the given ``react_state``.
Each call returns a brand-new set of closure tools that capture the
passed-in ``react_state``. The main agent and every sub-agent call this
once, achieving state isolation.
Args:
react_state: The per-agent mutable state container. The only
per-sub-agent isolation anchor. Tools read/write ``conv_id``,
``file_path``, ``generated_images``, ``image_url_map``,
``auto_data``, ``ratio_data``, ``matched``, ``skill_prompt``.
database_connector: Shared read-only DB connector (or None). Used by
``sql_query``.
knowledge_resources: Shared read-only knowledge resources (or None).
Used by ``knowledge_retrieve``.
Returns:
A dict mapping tool name -> tool callable, with 8 entries:
``load_skill``, ``load_tools``, ``knowledge_retrieve``, ``sql_query``,
``code_interpreter``, ``shell_interpreter``,
``execute_skill_script_file``, ``html_interpreter``.
"""
from kamidso.engine.app.openapi.api_v1.tools.skill_tools import make_load_skill
if database_connector is not None:
react_state.setdefault("database_connector", database_connector)
react_state.setdefault(
"database_connectors", {"default": database_connector}
)
react_state.setdefault(
"database_connector_labels", {"default": "database"}
)
load_skill = make_load_skill(react_state)
@tool(description="Resolve required tools for the selected skill.")
def load_tools() -> str:
matched = react_state.get("matched")
rm = get_resource_manager(CFG.SYSTEM_APP)
required_tools = matched.metadata.required_tools if matched else []
if not required_tools:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "No required tools specified",
}
]
},
ensure_ascii=False,
)
loaded = []
failed = []
for tool_name in required_tools:
try:
rm.build_resource_by_type(
ResourceType.Tool.value,
AgentResource(type=ResourceType.Tool.value, value=tool_name),
)
loaded.append(tool_name)
except Exception as e:
failed.append(f"{tool_name} ({e})")
chunks = []
if loaded:
chunks.append(
{"output_type": "text", "content": f"Loaded: {', '.join(loaded)}"}
)
if failed:
chunks.append(
{"output_type": "text", "content": f"Failed: {', '.join(failed)}"}
)
return json.dumps({"chunks": chunks}, ensure_ascii=False)
@tool(
description="Retrieve relevant information from the knowledge base. "
"Use this tool when the user question involves content that may be "
'in the knowledge base. Parameters: {{"query": "search query"}}'
)
async def knowledge_retrieve(query: str) -> str:
if not knowledge_resources:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "No knowledge base available",
}
]
},
ensure_ascii=False,
)
resource = knowledge_resources[0]
try:
chunks = await resource.retrieve(query)
if chunks:
content = "\n".join(
[f"[{i + 1}] {chunk.content}" for i, chunk in enumerate(chunks[:5])]
)
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": (
f"Retrieved {len(chunks)} relevant documents"
),
},
{"output_type": "markdown", "content": content},
]
},
ensure_ascii=False,
)
else:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "No relevant information found",
}
]
},
ensure_ascii=False,
)
except Exception as e:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"Knowledge retrieval failed: {str(e)}",
}
]
},
ensure_ascii=False,
)
@tool(
description=(
"对用户选择的数据库执行 SQL 查询(仅支持 SELECT)。"
'参数: {"sql": "SELECT 语句"}'
)
)
def sql_query(sql: str) -> str:
"""Execute a read-only SQL query against the selected database."""
if database_connector is None:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "未选择数据库,请先在左侧面板选择一个数据源。",
}
]
},
ensure_ascii=False,
)
sql_stripped = sql.strip().rstrip(";")
sql_upper = sql_stripped.upper().lstrip()
forbidden = [
"INSERT",
"UPDATE",
"DELETE",
"DROP",
"ALTER",
"TRUNCATE",
"CREATE",
"GRANT",
"REVOKE",
]
for kw in forbidden:
if sql_upper.startswith(kw):
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": (
f"安全限制: 不允许执行 {kw} 语句,"
f"仅支持 SELECT 查询。"
),
}
]
},
ensure_ascii=False,
)
try:
result = database_connector.run(sql_stripped)
from kamidso.engine.app.openapi.api_v1.tools.sql_query import (
_result_as_table,
render_query_observation,
)
from kamidso.engine.core.configs.model_config import interpreter_work_dir
columns, rows = _result_as_table(result)
db_type = str(getattr(database_connector, "db_type", "") or "")
work_dir = interpreter_work_dir(react_state.get("conv_id") or "default")
return render_query_observation(
react_state, columns, rows, db_type, work_dir=work_dir
)
except Exception as e:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"SQL 执行失败: {str(e)}",
}
]
},
ensure_ascii=False,
)
from kamidso.database_runtime.flags import database_runtime_v1_enabled
from kamidso.engine.app.openapi.api_v1.tools.code_interpreter import (
make_code_interpreter as _make_code_interpreter,
)
code_interpreter = _make_code_interpreter(
react_state,
sql_enabled=database_connector is not None
or bool(react_state.get("database_connectors")),
database_runtime=database_runtime_v1_enabled()
and (
database_connector is not None
or bool(react_state.get("database_connectors"))
or bool(react_state.get("db_runtime"))
),
native_connector=bool(react_state.get("native_connectors")),
)
@tool(
description="Execute shell/bash commands in a sandboxed environment. "
"Use this tool when you need to run shell commands such as ls, cat, "
"grep, curl, apt, pip, git, or any other CLI tool. "
"The sandbox provides resource limits (256MB memory, 30s timeout) "
"and process isolation. "
'Parameters: {"code": "shell command(s) to execute"}'
)
async def shell_interpreter(code: str) -> str:
"""Execute shell/bash commands in a sandboxed environment.
Uses dbgpt-sandbox LocalRuntime to run bash scripts with:
- Memory limit: 256MB
- Timeout: 30 seconds
- Process tree management (cleanup on timeout/error)
- Security validation (blocks dangerous patterns like rm -rf /)
Each call is independent — no state persists between calls.
"""
import uuid
if not code or not code.strip():
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "No command provided",
}
]
},
ensure_ascii=False,
)
try:
from kamidso.engine.sandbox.sandbox.execution_layer.base import (
ExecutionStatus,
SessionConfig,
)
from kamidso.engine.sandbox.sandbox.execution_layer.local_runtime import (
LocalRuntime,
)
except ImportError:
return json.dumps(
{
"chunks": [
{"output_type": "code", "content": code.strip()},
{
"output_type": "text",
"content": (
"Error: dbgpt-sandbox package is not installed. "
"Please install it with: pip install dbgpt-sandbox"
),
},
]
},
ensure_ascii=False,
)
session_id = f"bash_{uuid.uuid4().hex[:12]}"
runtime = LocalRuntime()
# Align the shell working dir to data/temp/{conv_id} (same as
# code_interpreter and the image-scan dir below) so parallel
# sub-agents do not collide in the project root. Previously this was
# ROOT_PATH, which was inconsistent with the image-scan dir.
from kamidso.engine.core.configs.model_config import interpreter_work_dir
cid = react_state.get("conv_id") or "default"
sandbox_work_dir = interpreter_work_dir(cid)
config = SessionConfig(
language="bash",
working_dir=sandbox_work_dir,
max_memory=256 * 1024 * 1024, # 256MB
timeout=30,
)
output_text = ""
try:
session = await runtime.create_session(session_id, config)
result = await session.execute(code)
if result.status == ExecutionStatus.SUCCESS:
output_text = result.output or ""
elif result.status == ExecutionStatus.TIMEOUT:
output_text = f"Execution timed out ({config.timeout}s limit)"
else:
output_text = result.error or "Unknown execution error"
if result.output:
output_text = result.output + "\n[ERROR]\n" + output_text
except Exception as e:
output_text = f"Sandbox execution error: {e}"
finally:
try:
await runtime.destroy_session(session_id)
except Exception:
pass
chunks: List[Dict[str, Any]] = [
{"output_type": "code", "content": code.strip()},
]
if output_text.strip():
chunks.append({"output_type": "text", "content": output_text.strip()})
else:
chunks.append(
{
"output_type": "text",
"content": "(no output)",
}
)
# ── Safety-net post-processing for skill script execution ──
# If the LLM used shell_interpreter to run a skill script despite
# the prompt requesting execute_skill_script_file, we still capture
# critical side-effects (ratio_data, images) into react_state.
_code_lower = code.strip().lower()
_is_skill_script = "skills/" in _code_lower and ".py" in _code_lower
if _is_skill_script and output_text.strip():
import shutil
from kamidso.engine.core.configs.model_config import STATIC_MESSAGE_IMG_PATH
# 1) Capture calculate_ratios.py output as ratio_data
if "calculate_ratios" in _code_lower:
try:
ratio_data = json.loads(output_text.strip())
if isinstance(ratio_data, dict):
react_state["ratio_data"] = ratio_data
logger.info(
"shell_interpreter: captured %d ratio_data keys",
len(ratio_data),
)
except Exception:
pass
# 2) Capture generate_charts.py output — look for image paths
# and copy them to static dir, same as execute_skill_script_file
if "generate_charts" in _code_lower:
try:
os.makedirs(STATIC_MESSAGE_IMG_PATH, exist_ok=True)
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}
# Try to parse JSON output for image paths
try:
chart_output = json.loads(output_text.strip())
if isinstance(chart_output, dict):
# Might be {"charts": {...}} or flat dict
chart_map = chart_output.get("charts", chart_output)
for name, abs_path in chart_map.items():
if isinstance(abs_path, str) and os.path.isfile(
abs_path
):
ext = os.path.splitext(abs_path)[1].lower()
if ext in IMAGE_EXTS:
unique_name = (
f"{uuid.uuid4().hex[:8]}_"
f"{os.path.basename(abs_path)}"
)
dest = os.path.join(
STATIC_MESSAGE_IMG_PATH, unique_name
)
shutil.copy2(abs_path, dest)
img_url = f"/images/{unique_name}"
react_state.setdefault(
"generated_images", []
).append(img_url)
orig_stem = os.path.splitext(
os.path.basename(abs_path)
)[0].lower()
react_state.setdefault("image_url_map", {})[
orig_stem
] = img_url
except (json.JSONDecodeError, TypeError):
pass
# Also scan the output dir for any new .png files
cid = react_state.get("conv_id") or "default"
from kamidso.engine.core.configs.model_config import interpreter_work_dir
out_dir = interpreter_work_dir(cid)
if os.path.isdir(out_dir):
for fname in os.listdir(out_dir):
ext = os.path.splitext(fname)[1].lower()
if ext in IMAGE_EXTS:
abs_path = os.path.join(out_dir, fname)
orig_stem = os.path.splitext(fname)[0].lower()
if orig_stem not in react_state.get(
"image_url_map", {}
):
unique_name = f"{uuid.uuid4().hex[:8]}_{fname}"
dest = os.path.join(
STATIC_MESSAGE_IMG_PATH, unique_name
)
shutil.copy2(abs_path, dest)
img_url = f"/images/{unique_name}"
react_state.setdefault(
"generated_images", []
).append(img_url)
react_state.setdefault("image_url_map", {})[
orig_stem
] = img_url
# Append image URL summary for LLM reference
all_images = react_state.get("generated_images", [])
if all_images:
img_summary = (
"已生成的图片URL(在生成HTML报告时请使用这些URL):\n"
+ "\n".join(f" - {url}" for url in all_images)
)
chunks.append({"output_type": "text", "content": img_summary})
logger.info(
"shell_interpreter: captured %d images for skill script",
len(react_state.get("image_url_map", {})),
)
except Exception as e:
logger.warning(
"shell_interpreter: image post-processing failed: %s", e
)
return json.dumps({"chunks": chunks}, ensure_ascii=False)
@tool(
description="执行技能scripts目录下的脚本文件。参数: "
'{"skill_name": "技能名称", "script_file_name": "脚本文件名", "args": {参数}}'
)
async def execute_skill_script_file(
skill_name: str, script_file_name: str, args: Optional[dict] = None
) -> str:
"""Execute a script file from a skill's scripts directory.
After execution, any new image files (.png, .jpg, etc.) generated
by the script are automatically copied to the static images directory
and their URLs are returned in the output chunks.
"""
import shutil
import uuid
from kamidso.engine.core.agent.skill.manage import get_skill_manager
from kamidso.engine.core.configs.model_config import STATIC_MESSAGE_IMG_PATH
try:
from kamidso.engine.app.openapi.api_v1.tools.skill_runtime import (
activate_loaded_skill,
blocked_observation,
budget_block,
compact_handoff_evidence,
observation_suffix,
parse_skill_contract,
record_skill_result,
)
from kamidso.engine.core.configs.model_config import interpreter_work_dir
args = dict(args or {})
blocked = budget_block(react_state, skill_name, args)
if blocked:
return blocked_observation(blocked)
activate_loaded_skill(skill_name)
evidence = compact_handoff_evidence(react_state)
if evidence and "previous_evidence" not in args:
args["previous_evidence"] = evidence
sm = get_skill_manager(CFG.SYSTEM_APP)
cid = react_state.get("conv_id") or "default"
out_dir = interpreter_work_dir(cid)
# Auto-inject the correct file path from react_state into args.
# The LLM sometimes corrupts the uploaded file path (e.g. changing
# 'dbgpt-app' to 'kamidso.engine.app'), so we override any file-path-like
# keys in args with the known-good path from react_state.
real_file_path = react_state.get("file_path")
if real_file_path and args:
_FILE_PATH_KEYS = {
"input_file",
"file_path",
"data_path",
"csv_path",
"excel_path",
"data_file",
}
for key in list(args.keys()):
if key in _FILE_PATH_KEYS:
args[key] = real_file_path
result_str = await sm.execute_skill_script_file(
skill_name,
script_file_name,
args or {},
output_dir=out_dir,
)
# Read script source code and prepend as a 'code' chunk
# so the frontend can display it in the left pane.
try:
_skill_path = sm._get_skill_path(skill_name)
_sf = script_file_name.lstrip("/\\")
if _sf.startswith("scripts/") or _sf.startswith("scripts\\"):
_sf = _sf[8:]
_script_abs = os.path.join(_skill_path, "scripts", _sf)
with open(_script_abs, "r", encoding="utf-8") as _f:
_script_source = _f.read()
except Exception:
_script_source = None
# Post-process: copy image files to static dir and replace
# absolute paths with /images/ URLs.
try:
result_obj = json.loads(result_str)
chunks = result_obj.get("chunks", [])
# Prepend script source code as a 'code' chunk
if _script_source:
chunks.insert(
0,
{
"output_type": "code",
"content": _script_source,
},
)
os.makedirs(STATIC_MESSAGE_IMG_PATH, exist_ok=True)
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}
for chunk in chunks:
if chunk.get("output_type") == "image":
abs_path = chunk["content"]
if os.path.isabs(abs_path) and os.path.isfile(abs_path):
ext = os.path.splitext(abs_path)[1].lower()
if ext in IMAGE_EXTS:
unique_name = (
f"{uuid.uuid4().hex[:8]}_"
f"{os.path.basename(abs_path)}"
)
dest = os.path.join(
STATIC_MESSAGE_IMG_PATH, unique_name
)
shutil.copy2(abs_path, dest)
img_url = f"/images/{unique_name}"
chunk["content"] = img_url
react_state.setdefault("generated_images", []).append(
img_url
)
# Also store a map: original filename (no ext)
# -> served URL for template placeholder
# resolution.
orig_stem = os.path.splitext(
os.path.basename(abs_path)
)[0].lower()
react_state.setdefault("image_url_map", {})[
orig_stem
] = img_url
# Append image URL summary for LLM reference
all_images = react_state.get("generated_images", [])
if all_images:
img_summary = (
"已生成的图片URL(在生成HTML报告时请使用这些URL):\n"
+ "\n".join(f" - {url}" for url in all_images)
)
chunks.append({"output_type": "text", "content": img_summary})
auto_data = react_state.get("auto_data")
if not isinstance(auto_data, dict):
auto_data = {}
react_state["auto_data"] = auto_data
filtered_chunks = []
for chunk in chunks:
if chunk.get("output_type") != "text":
filtered_chunks.append(chunk)
continue
content = chunk.get("content") or ""
cleaned, extracted = _extract_auto_data_markers(content)
if extracted:
auto_data.update(extracted)
logger.info(
"execute_skill_script_file: captured auto_data keys=%s",
sorted(extracted.keys()),
)
if cleaned:
chunk["content"] = cleaned
filtered_chunks.append(chunk)
elif not extracted:
filtered_chunks.append(chunk)
chunks = filtered_chunks
# Compatibility path for existing financial-report skill.
if script_file_name == "calculate_ratios.py":
for chunk in chunks:
if chunk.get("output_type") == "text":
try:
ratio_data = json.loads(chunk["content"])
react_state["ratio_data"] = ratio_data
except Exception:
pass
packed = json.dumps({"chunks": chunks}, ensure_ascii=False)
contract = parse_skill_contract(packed)
record_skill_result(react_state, skill_name, args, contract)
chunks.append(
{
"output_type": "text",
"content": observation_suffix(react_state, contract),
}
)
return json.dumps({"chunks": chunks}, ensure_ascii=False)
except (json.JSONDecodeError, KeyError):
record_skill_result(react_state, skill_name, args, None, failed=True)
return result_str
except Exception as e:
from kamidso.engine.app.openapi.api_v1.tools.skill_runtime import (
record_skill_result,
)
record_skill_result(react_state, skill_name, args or {}, None, failed=True)
return json.dumps(
{"chunks": [{"output_type": "text", "content": f"Error: {str(e)}"}]},
ensure_ascii=False,
)
@tool(
description="将 HTML 渲染为可交互的网页报告,这是向用户展示网页报告的唯一方式。"
"گزارش باید به زبان فارسی و راست‌به‌چپ باشد (dir=rtl lang=fa). "
"【一次性】一次调用即可把【完整】报告渲染出来;同一份报告【禁止】重复调用本工具,"
"渲染成功后若目标已达成请直接 terminate。"
"【默认用法】直接传入完整的 HTML 字符串:"
'{"html": "<html>...</html>", "title": "报告标题"}。'
"你需要自己生成完整的 HTML 代码"
"(包含 <!DOCTYPE html>、<html>、<head>、<body> 等),"
"然后传给 html 参数即可。"
"HTML 可以很长,没有长度限制,不需要分段传入;"
"若报告含多部分内容,请合并进【同一份】HTML 一次性渲染,"
"不要分多次生成多份报告。"
"【禁止】不要用 code_interpreter 写 HTML 再 print,"
"不要用 code_interpreter 把 HTML 写入文件再读取,"
"直接把 HTML 传给本工具即可。"
"【技能模式 - 仅在使用技能时可选】如果正在使用技能(skill),可以用模板模式:"
'{"template_path": "技能名/templates/模板.html", '
'"data": {"KEY": "值"}, "title": "标题"}。'
'也可以用文件模式:{"file_path": "/path/to/report.html"}'
)
async def html_interpreter(
html: str = "",
title: str = "Report",
file_path: str = "",
template_path: str = "",
data: dict | str = None,
) -> str:
"""Render HTML as an interactive web report.
Default usage: pass a complete HTML string via the `html` parameter.
The HTML can be arbitrarily long — no length limit, no chunking needed.
Skill template mode (optional): pass `template_path` (relative to skills
dir) plus a `data` dict whose keys match {{PLACEHOLDER}} tokens in the
template. The backend reads the template and performs all replacements.
Legacy fallback: `file_path` reads HTML from a file on disk.
"""
import re
from kamidso.engine.core.configs.model_config import STATIC_MESSAGE_IMG_PATH
# ── Mode 1: template_path + data ──────────────────────────────
if template_path and template_path.strip():
tp = template_path.strip()
skills_dir = Path(DEFAULT_SKILLS_DIR).expanduser().resolve()
target = (skills_dir / tp).resolve()
# Security: must be under skills_dir
try:
target.relative_to(skills_dir)
except ValueError:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"Invalid template_path: {tp}",
}
]
},
ensure_ascii=False,
)
if not target.is_file():
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": (
f"Template not found: {tp}. "
"This skill does not have HTML templates. "
"Please retry by calling html_interpreter "
"with the `html` parameter instead — "
"generate the complete HTML report code "
"yourself and pass it directly via "
'{"html": "<html>...</html>", '
'"title": "report title"}.'
),
}
]
},
ensure_ascii=False,
)
try:
raw_template = target.read_text(encoding="utf-8")
except Exception as e:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"Error reading template: {e}",
}
]
},
ensure_ascii=False,
)
# Replace {{KEY}} placeholders with values from data dict
# Sometimes the LLM passes data as a JSON string instead of a dict
replacements = data
if isinstance(replacements, str):
try:
replacements = json.loads(replacements)
except Exception as e:
logger.warning(
f"html_interpreter failed to parse string data as json: {e}"
)
# Attempt to fix truncated JSON by appending closing
# braces/quotes
try:
fixed = str(replacements).rstrip()
if not fixed.endswith("}"):
if fixed.endswith('"'):
fixed += "}"
else:
fixed += '"}'
replacements = json.loads(fixed)
except Exception:
replacements = {}
if not isinstance(replacements, dict):
replacements = {}
auto_data = react_state.get("auto_data", {})
if isinstance(auto_data, dict):
replacements = {**auto_data, **replacements}
# Merge LLM replacements with ratio_data from calculate_ratios.py
ratio_data = react_state.get("ratio_data", {})
if isinstance(ratio_data, dict):
# auto_data / LLM data overwrites ratio_data if keys overlap
merged = {**ratio_data, **replacements}
replacements = merged
# Auto-resolve CHART_* placeholders from generated images.
# image_url_map: {
# "financial_overview": "/images/abc_financial_overview.png"
# }
# Template uses:
# {{CHART_FINANCIAL_OVERVIEW}}
# -> /images/abc_financial_overview.png
image_url_map = react_state.get("image_url_map", {})
if isinstance(image_url_map, dict):
for stem, url in image_url_map.items():
chart_key = f"CHART_{stem.upper()}"
if chart_key not in replacements:
replacements[chart_key] = url
def _replace_placeholder(m):
key = m.group(1)
return str(replacements.get(key, ""))
html = re.sub(r"\{\{([A-Z_0-9]+)\}\}", _replace_placeholder, raw_template)
if not title or title == "Report":
title = target.stem
logger.info(
"html_interpreter: template=%s, %d placeholders replaced, "
"html=%d chars",
tp,
len(replacements),
len(html),
)
# ── Mode 2: file_path ─────────────────────────────────────────
elif file_path and file_path.strip():
fp = file_path.strip()
if not os.path.isfile(fp):
cid = react_state.get("conv_id") or "default"
from kamidso.engine.core.configs.model_config import interpreter_work_dir
alt = os.path.join(interpreter_work_dir(cid), os.path.basename(fp))
if os.path.isfile(alt):
fp = alt
else:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"File not found: {file_path}",
}
]
},
ensure_ascii=False,
)
try:
with open(fp, "r", encoding="utf-8") as f:
html = f.read()
if not title or title == "Report":
title = os.path.splitext(os.path.basename(fp))[0]
logger.info(
"html_interpreter: read %d chars from file %s",
len(html),
fp,
)
except Exception as e:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"Error reading file: {e}",
}
]
},
ensure_ascii=False,
)
# ── Mode 3: inline html ──────────────────────────────────────
# Unescape literal \n sequences that LLM may produce.
# IMPORTANT: Only apply this unescape when html was provided directly
# (inline mode). Template mode (Mode 1) and file mode (Mode 2) produce
# real HTML that already contains actual newlines and may contain JS
# regex literals like /\\n/ which must NOT be collapsed into real
# newlines — doing so corrupts the JS and breaks chart rendering.
if html and isinstance(html, str) and not template_path and not file_path:
if "\\n" in html:
html = html.replace("\\n", "\n")
if "\\t" in html:
html = html.replace("\\t", "\t")
if not html or not html.strip():
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "No HTML content provided",
}
]
},
ensure_ascii=False,
)
# Post-process: fix image URLs that the LLM may have guessed wrong.
# Files in STATIC_MESSAGE_IMG_PATH are named "{uuid8}_{original}.ext".
# The LLM might reference "/images/original.ext" (without UUID prefix)
# or even just "original.ext". Build a lookup and replace.
fixed_html = html.strip()
try:
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}
# Map: lowercase base name (without uuid prefix) -> served path
# e.g. "monthly_sales_trend.png"
# -> "/images/a1b2c3ff_monthly_sales_trend.png"
name_to_served: Dict[str, str] = {}
if os.path.isdir(STATIC_MESSAGE_IMG_PATH):
for fname in os.listdir(STATIC_MESSAGE_IMG_PATH):
ext = os.path.splitext(fname)[1].lower()
if ext not in IMAGE_EXTS:
continue
# Strip the 8-char hex UUID prefix + underscore
# Pattern: <8 hex chars>_<original_name>
m = re.match(r"^[0-9a-f]{8}_(.+)$", fname, re.IGNORECASE)
if m:
base_name = m.group(1).lower()
served_path = f"/images/{fname}"
# Keep the latest (last alphabetically = most recent
# UUID)
name_to_served[base_name] = served_path
if name_to_served:
# Replace patterns like:
# src="/images/monthly_sales_trend.png"
# src="images/monthly_sales_trend.png"
# src="monthly_sales_trend.png"
# with the correct served path.
def _fix_img_src(match: re.Match) -> str:
prefix = match.group(1) # src=" or src='
raw_path = match.group(2) # the path value
quote = match.group(3) # closing quote
# Extract just the filename from the path
filename = raw_path.rsplit("/", 1)[-1].lower()
# Check if it's already a correct served path
if re.match(r"^[0-9a-f]{8}_.+$", filename, re.IGNORECASE):
return match.group(0) # Already has UUID prefix
if filename in name_to_served:
return f"{prefix}{name_to_served[filename]}{quote}"
return match.group(0) # No match, keep original
# Match src="..." or src='...' containing image references
fixed_html = re.sub(
r"""(src\s*=\s*["'])"""
r"""([^"']+\.(?:png|jpg|jpeg|gif|svg|webp))"""
r"""(["'])""",
_fix_img_src,
fixed_html,
flags=re.IGNORECASE,
)
except Exception:
pass # If post-processing fails, use original HTML
# Auto-append images generated during this session that the LLM
# forgot to include in the HTML.
try:
gen_images = react_state.get("generated_images", [])
if gen_images:
# Extract all image filenames already referenced in the HTML
# (e.g. "time_series_trend.png" from any src="...time_series_trend.png")
html_img_stems = set(
re.sub(r"^[0-9a-f]+_", "", os.path.basename(src))
for src in re.findall(
r'<img[^>]+src=["\']([^"\']+)["\']', fixed_html, re.IGNORECASE
)
)
# An image is "missing" only when neither its exact URL nor its
# stem (filename with UUID prefix stripped) is already covered.
def _img_stem(url):
return re.sub(r"^[0-9a-f]+_", "", os.path.basename(url))
missing = [
url
for url in gen_images
if url not in fixed_html and _img_stem(url) not in html_img_stems
]
if missing:
imgs_html = "".join(
f'<div style="margin:16px 0">'
f'<img src="{url}" '
f'style="max-width:100%;height:auto;'
f'border-radius:8px">'
f"</div>"
for url in missing
)
section = (
'<div style="margin-top:32px">'
"<h2>📊 分析图表</h2>"
f"{imgs_html}</div>"
)
# Insert before </body> if present, otherwise append
if "</body>" in fixed_html.lower():
fixed_html = re.sub(
r"(</body>)",
section + r"\1",
fixed_html,
count=1,
flags=re.IGNORECASE,
)
else:
fixed_html += section
except Exception:
pass
# A leading text chunk gives the ReAct LLM an explicit success signal.
# Without it the observation is just the raw HTML source, which the model
# cannot tell apart from "not done yet" and tends to re-render a second
# report. The frontend ignores this text for rendering (the html chunk
# below drives the report); it only steers the agent loop to terminate.
chunks: List[Dict[str, Any]] = [
{
"output_type": "text",
"content": (
"✅ HTML 报告已成功渲染并展示给用户。报告任务已完成,"
"请勿重复调用 html_interpreter 生成报告。"
"若全部目标已达成,请直接调用 terminate 结束。"
),
},
{"output_type": "html", "content": fixed_html, "title": title},
]
return json.dumps({"chunks": chunks}, ensure_ascii=False)
tools = {
"load_skill": load_skill,
"load_tools": load_tools,
"knowledge_retrieve": knowledge_retrieve,
"code_interpreter": code_interpreter,
"shell_interpreter": shell_interpreter,
"execute_skill_script_file": execute_skill_script_file,
"html_interpreter": html_interpreter,
}
from kamidso.database_runtime.flags import agent_sees_sql_query
if agent_sees_sql_query():
tools["sql_query"] = sql_query
return tools