Files
kamidso-back/kamidso/engine/app/openapi/api_v1/agentic_data_api.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

5385 lines
220 KiB
Python

import asyncio
import io
import json
import logging
import os
import re
import shutil
import tempfile
import uuid
import zipfile
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from fastapi import (
APIRouter,
Body,
Depends,
File,
HTTPException,
Query,
Request,
UploadFile,
)
from fastapi.responses import StreamingResponse
from starlette.concurrency import run_in_threadpool
from kamidso.engine.app.openapi.api_v1.native_dbgpt import (
bind_agent_prompt,
build_agent_memory,
log_prompt_metadata,
op_snapshot_dir,
prepare_react_gpts_memory, # noqa: F401 — re-export for tests
use_native_dbgpt_prompt,
)
from kamidso.engine.app.openapi.api_v1.react_runtime import (
REACT_AGENT_MEMORY_CACHE,
REACT_CONVERSATION_AGENT_TASKS,
REACT_CONVERSATION_LOCKS,
acquire_conversation_run,
end_react_turn,
)
from kamidso.engine.app.openapi.api_view_model import (
ConversationVo,
Result,
)
from kamidso.engine.core._private.config import Config
from kamidso.engine.core._private.pydantic import BaseModel as _BaseModel
from kamidso.engine.core.agent.core.context import ContextBudgetConfig
from kamidso.engine.core.agent.resource.tool.base import tool
from kamidso.engine.core.agent.skill.manage import get_skill_manager
from kamidso.engine.core.component import ComponentType
from kamidso.engine.core.configs.model_config import SKILLS_DIR, resolve_root_path
from kamidso.engine.core.core import PromptTemplate
from kamidso.engine.core.model.cluster import WorkerManagerFactory
from kamidso.engine.core.util.json_utils import parse_or_raise_error
from kamidso.engine.serve.datasource.manages import ConnectorManager
from kamidso.engine.serve.utils.auth import UserRequest, get_user_from_headers
from .thinking_stream import (
clean_thinking_chunk,
live_thinking_display,
thinking_display_delta,
)
from .attachment_react_adapter import (
AttachmentInputError,
SessionAttachmentContext,
build_file_context,
build_input_files_v2,
prepare_react_attachments,
react_state_patch,
resolve_legacy_chat_file_path,
scrub_react_history_for_share,
)
from .react_final import AgentFinalAnswer, FinalAnswerAssembler
from .subagent.dispatcher import DISPATCH_PROMPT_SECTION, make_dispatch_tool
from .subagent.history import (
build_subagent_history_snapshot,
fail_running_subagent_history,
update_subagent_history,
)
router = APIRouter()
CFG = Config()
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from kamidso.engine.core.agent import AgentMessage
from kamidso.engine.core.agent.core.memory.gpts import GptsMemory
from kamidso.engine.core.agent.resource.connector.manager import ConnectorManager
from kamidso.engine.core.agent.resource.tool.base import BaseTool
_FATAL_LLM_ERROR_MARKERS = ("LLMServer Generate Error", "proxy_llm_client")
_REACT_SCRATCHPAD_MARKERS = ("Thought:", "Action:", "Action Input:", "Observation:")
def _is_fatal_llm_output(text: str) -> bool:
"""Detect worker/LLM failures that must not enter the ReAct retry loop."""
if not text:
return False
return any(marker in text for marker in _FATAL_LLM_ERROR_MARKERS)
def _is_react_scratchpad_content(content: str) -> bool:
"""True when stored AI text is an intermediate ReAct step, not a final answer."""
if not content:
return False
stripped = content.strip()
if any(marker in stripped for marker in _FATAL_LLM_ERROR_MARKERS):
return True
return any(stripped.startswith(marker) for marker in _REACT_SCRATCHPAD_MARKERS)
def _conversation_agent_lock(conv_id: str) -> asyncio.Lock:
lock = REACT_CONVERSATION_LOCKS.get(conv_id)
if lock is None:
lock = asyncio.Lock()
REACT_CONVERSATION_LOCKS[conv_id] = lock
return lock
async def _cancel_conversation_agent(conv_id: str) -> None:
task = REACT_CONVERSATION_AGENT_TASKS.pop(conv_id, None)
if task is not None and not task.done():
await _cancel_and_await_agent_task(task)
def _response_language(ext_info: Optional[Dict[str, Any]]) -> str:
if isinstance(ext_info, dict):
return str(ext_info.get("response_language") or "")
return ""
def _fatal_llm_user_message(ext_info: Optional[Dict[str, Any]]) -> str:
if isinstance(ext_info, dict) and str(ext_info.get("response_language", "")).startswith("fa"):
return (
"موتور مدل موقتاً در دسترس نیست. لطفاً backend را یک‌بار restart کنید "
"و دوباره تلاش کنید."
)
return "LLM engine is temporarily unavailable. Restart the backend and try again."
def _stream_failure_user_message(ext_info: Optional[Dict[str, Any]]) -> str:
"""User-facing message when the ReAct stream aborts before a final answer."""
lang = ""
if isinstance(ext_info, dict):
lang = str(ext_info.get("response_language") or "").lower()
if lang.startswith("fa") or not lang:
return "در تولید پاسخ خطایی رخ داد. لطفاً دوباره تلاش کنید."
return "Sorry, an error occurred while generating the answer. Please try again."
DEFAULT_SKILLS_DIR = SKILLS_DIR
AUTO_DATA_MARKER_PATTERN = re.compile(
r"###([A-Z0-9_]+)_START###\s*(.*?)\s*###\1_END###", re.DOTALL
)
_REACT_BRIEF_LIMITS = """
Action Intention: What this step will do, plain text, MUST be concise (<= 8 English words
or a short Persian phrase). Do not use ellipsis. Never use Chinese.
Action Reason: Why this action is needed now, plain text, MUST be concise (<= 12 English
words or one short Persian sentence). Do not use ellipsis. Never use Chinese.
"""
def _resolve_agent_language(ext_info: Optional[Dict[str, Any]]) -> str:
"""Default to English for agent internals; never default to Chinese."""
if isinstance(ext_info, dict):
raw = ext_info.get("agent_language") or ext_info.get("language")
if isinstance(raw, str) and raw.strip():
lang = raw.strip().lower().replace("_", "-")
if lang.startswith("zh"):
return "en"
return lang.split("-")[0]
return "en"
def _skill_prompt_sections(
skill_name: str, skill_text: str, skill_source: str = "user"
) -> Tuple[str, str]:
skill_prompt_context = f"""
## Loaded skill instructions ({skill_name})
Follow the instructions below exactly:
{skill_text}
"""
if skill_source == "datasource":
source_line = (
f"1. A database is attached this turn. Use {skill_name} as internal "
"guidance. The user did not select a skill."
)
elif skill_source == "file":
source_line = f"1. Analyzing the attached file with skill: {skill_name}"
else:
source_line = f"1. The user selected skill: {skill_name}"
execution_instruction = f"""
## Execution requirements
{source_line}
2. Follow the skill workflow step by step
3. Read the skill instructions and identify each required tool call
4. Execute tools in order until the skill goal is complete
"""
return skill_prompt_context, execution_instruction
_KAMIDSO_IDENTITY = (
"You are Kamidso (کامیدسو), an enterprise AI agent deployed locally "
"inside the organization. If asked your name, who you are, or what "
"product this is, answer Kamidso / کامیدسو. Never identify yourself as DB-GPT. "
"You work across all organizational domains — for example finance, HR, "
"warehouse, support, and operations — and handle analysis, root-cause "
"investigation, decision support, advisory, and Q&A. Those examples are "
"not a limit. You are speaking with the organization, the company, and "
"its managers and decision-makers. Unlike a general chatbot such as "
"ChatGPT, you work on the organization's own data. Whether or not a "
"skill runs, answers must be complete, detailed, and professional, "
"with tables, mappings, hypotheses, causes, and decision options when "
"they help the reader act, and in the domain's own terminology."
)
def _system_clock_line() -> str:
"""Local OS clock in Gregorian and Jalali, captured when the chat prompt is built."""
from kamidso.assistant.system_clock import format_system_clock_line
return format_system_clock_line()
# Appended to every react workflow system prompt so follow-ups keep history as
# reference context while treating the latest Question as the active task.
# Injected into the chat system prompt (jinja2). Runtime vars come from
# ConversableAgent._load_thinking_messages: current_step, steps_remaining,
# force_wrap_up, near_budget, plus max_steps from ReActAgent reply context.
_STEP_BUDGET_PROMPT_SECTION = """
## Step budget and wrap-up
The maximum number of steps you can take is {{ max_steps }}.
{% if current_step is defined and steps_remaining is defined %}
You are on step {{ current_step }} of {{ max_steps }} \
({{ steps_remaining }} remaining including this step).
{% endif %}
{% if force_wrap_up %}
CRITICAL WRAP-UP: Your step budget is exhausted or about to be cut off.
Do NOT call any more analysis tools.
Immediately use Action: terminate.
In Action Input.result, summarize EVERYTHING already checked: findings, metrics,
evidence, conclusions, open risks, and incomplete work.
Do not invent data you did not observe.
{% elif near_budget %}
NEAR BUDGET: Few steps remain. Prioritize finishing and prepare a terminate
summary of all checked evidence soon.
{% endif %}
When the environment observation contains "[STEP BUDGET WRAP-UP]" or
"[STEP BUDGET WARNING]", obey it over further tool exploration.
""".strip()
_MULTI_TURN_PROMPT_SECTION = """
## Multi-turn conversation
Prior user/assistant turns may appear above the current Question.
Use them to resolve references and keep topic continuity.
The latest Question is the active task.
Short confirmations (yes / آره / بله / باشه / ok / اره میخوام / sure):
If the previous assistant reply offered a pending next step (e.g. "اگر بخواهید
می‌توانم…", "I can also check…"), that offer IS the active task. Execute it.
Stay on the same topic, entities, time ranges, and metrics already discussed.
Do NOT invent a new analysis theme or a broad new report just because the user
said yes.
Do NOT reopen prior completed analysis only to apologize or "correct" it unless
the user asked you to verify it. Answer the pending offer only.
Do NOT repeat, rewrite, or re-summarize a previous final answer unless the user
explicitly asks to repeat or refine that prior answer.
If the new question changes the metric, filter, time range, or comparison,
recompute for the new ask — do not reuse the previous ranking or conclusion by
default.
Call html_interpreter only when Report mode is ON for this turn (user toggled
the گزارش control in the composer). When Report mode is OFF, never call
html_interpreter — even if the user writes گزارش/report/dashboard — and answer
with a complete `terminate` covering every asked point (who, window, metrics,
causes, actions) in compact markdown. Skip cover-page preamble. Never stop
after the first heading. When Report mode is ON: first `todowrite` a coverage
plan from recent chat Q&A plus the current Question; inspect database structure
(schema / real field names — never guess); load a matching catalog skill when
the description fits; push JOIN/GROUP/COUNT to the database; then call
`html_interpreter` once with a complete Persian RTL HTML document (the ONLY way
to show the web report); THEN terminate with a FULL independent analysis in
Persian covering every asked section — not a short summary and not "see the
report above". Never paste Thought/Action protocol into terminate.result. Do
not print HTML via `code_interpreter` and terminate without `html_interpreter`.
If the user refers to "the report above" / "گزارش بالا", use the previous
HTML report already present in conversation history. Do not claim the report
is missing when prior report content is in context.
## Prior findings and schema honesty
Never retract a prior numeric/table claim from this conversation unless you
re-run the SAME field path that produced it and get a contradictory result.
Before saying a field "does not exist", list TOP-LEVEL keys of recent documents
(sort newest first). Do not conclude from inspecting only one nested object
(e.g. `profile`) that sibling fields like `acquisition` are missing.
Prefer sampling newest docs: sort by created_at/_id descending.
""".strip()
# Wall-clock budget for a single react user turn (many SQL/tool steps).
_REACT_AGENT_MAX_TIMEOUT_SECONDS = 1800
_SHORT_CONFIRMATION_RE = re.compile(
r"^(?:"
r"آره|اره|بله|باشه|چشم|اوکی|ok|okay|yes|yep|yeah|sure|لطفا|please|"
r"(?:آره|اره|بله)\s*(?:می\s*خوام|می‌خوام|میخواهم|لطفا(?:ً)?|بکن|انجام\s*بده)?|"
r"(?:yes|ok|okay|sure)\s*(?:please|do\s*it)?"
r")[\s!.٫،.]*$",
re.IGNORECASE | re.UNICODE,
)
_OFFER_HINT_RE = re.compile(
r"(اگر\s*بخواهید|اگه\s*بخوای|می‌تونم|میتونم|می‌توانم|میتوانم|"
r"want me to|if you(?:'d| would) like|i can (?:also )?(?:check|look|analyze)|"
r"shall i|should i)",
re.IGNORECASE | re.UNICODE,
)
def _normalize_confirm_text(text: str) -> str:
normalized = (text or "").strip()
normalized = normalized.replace("ي", "ی").replace("ك", "ک")
return re.sub(r"\s+", " ", normalized)
def _is_short_confirmation(text: str) -> bool:
"""True for short yes/ok affirmations that accept a pending next step."""
normalized = _normalize_confirm_text(text)
if not normalized or len(normalized) > 48:
return False
return bool(_SHORT_CONFIRMATION_RE.match(normalized))
def _report_mode_enabled(ext_info: Optional[Dict[str, Any]]) -> bool:
if not isinstance(ext_info, dict):
return False
raw = ext_info.get("report_mode")
if isinstance(raw, bool):
return raw
if isinstance(raw, (int, float)):
return bool(raw)
if isinstance(raw, str):
return raw.strip().lower() in {"1", "true", "yes", "on"}
return False
def _report_mode_rule(report_mode: bool) -> str:
if report_mode:
return (
"**Report mode ON (user toggled گزارش):** Playbook, in order: "
"(1) First Action MUST be `todowrite` — one todo per required section "
"synthesized from the current Question, recent historical_dialogues, "
"and any [Previous structured report] snapshot. "
"(2) Discover structure: schema:true / real collection field names. "
"Never guess a field. "
"(3) If a catalog skill description matches this domain, `load_skill` "
"before ad-hoc code. "
"(4) Compute in `code_interpreter` with `database().query` / "
"`database().aggregate`: JOIN / $lookup, GROUP BY / $group, "
"COUNT on the server. Python only formats the small result. "
"(5) `html_interpreter` once with a complete HTML document. Visible "
"text, titles, labels, table headers and chart labels MUST be Persian "
"(Farsi); set dir=\"rtl\" lang=\"fa\" on <html>. This is the ONLY way "
"to display the web report. "
"(6) `terminate` with a FULL independent Persian analysis (same depth "
"as a thorough chat answer) — title, method, window, tables, groups, "
"causes, actions, prioritized list, caveats. Not a short summary. "
"Do not collapse the whole report into one todo, and do not skip the "
"plan just because a prior chat already analyzed the topic. "
"Never paste Thought/Action protocol into terminate.result. "
"Do NOT print HTML via `code_interpreter` then terminate without "
"`html_interpreter`."
)
return (
"**Report mode OFF (normal chat):** Do NOT call `html_interpreter` in "
"this turn, even if the user writes گزارش/report/dashboard. Answer with a "
"complete `terminate` result that finishes every section the user asked "
"for (names, numbers, time window, causes, next actions). Use compact "
"markdown — bullets and small tables, not a title page. Never end after "
"heading 1. Questions about a previous report already in history stay as "
"chat answers — do not create a new HTML report unless report mode "
"is ON."
)
REPORT_DELIVERABLE_RULE = """
## Report deliverable (this turn)
Call `html_interpreter` once with a complete Persian RTL HTML document — the
ONLY way to show the web report. Then `terminate.result` MUST be a complete
standalone analysis in Persian, not a short executive blurb. Include: title;
method and time window; KPIs; tables; risk/segment groups; cause for each
group; actions; a prioritized named list; limitations. Do not write «به گزارش
بالا مراجعه کنید». The chat text and the HTML report are both required and
independent. Repeat tables in the chat text when the user asked for them.
Expand skill `summary` into this full answer.
""".strip()
HTML_INTERPRETER_TOOL_DESC = """
**html_interpreter**: Render HTML as an interactive web report (the ONLY way
to display reports in the UI). Default usage:
{"html": "<html dir='rtl' lang='fa'>complete HTML</html>", "title": "عنوان گزارش"}.
Generate complete HTML yourself (DOCTYPE, html, head, body, CSS). No length
limit. Visible text MUST be Persian. Design it as a calm analysis document:
Vazirmatn or Tahoma, white page, generous spacing, one accent color, KPI cards,
clear headings, and readable tables. Inline all CSS. dir=rtl lang=fa on html.
Template mode (only if the skill has
templates/): {"template_path": "skill/templates/xxx.html", "data": {...},
"title": "title"}. File mode: {"file_path": "/path/to/report.html"}.
Do not use code_interpreter to write HTML.
""".strip()
def _todowrite_policy(report_mode: bool) -> str:
if report_mode:
return (
"When Report mode is ON, `todowrite` is MANDATORY before "
"`html_interpreter`, even if converting a previous chat answer "
"looks like one step.\n"
"- First Action: create the FULL todo list from the current "
"Question plus recent chat/report context. One todo per required "
"section so nothing the user asked is forgotten.\n"
"- Call `todowrite` with the FULL todo list (all items) each time "
"you update.\n"
"- Mark exactly ONE task as `in_progress` at a time.\n"
"- Mark tasks `completed` immediately after finishing each one.\n"
"- Skip the plan only for a tiny tweak to an existing report "
"(one title/field change)."
)
return (
"For complex tasks that require 3 or more steps, use the `todowrite` "
"tool to create a structured task plan BEFORE starting work. This "
"helps users track your progress.\n"
"- Call `todowrite` with the FULL todo list (all items) each time "
"you update.\n"
"- Mark exactly ONE task as `in_progress` at a time.\n"
"- Mark tasks `completed` immediately after finishing each one.\n"
"- Do NOT use todowrite for simple single-step tasks."
)
def _extract_pending_offer(assistant_text: str) -> Optional[str]:
"""Prefer the closing offer block from the previous assistant reply."""
text = (assistant_text or "").strip()
if not text:
return None
parts = [part.strip() for part in re.split(r"\n\s*\n", text) if part.strip()]
for part in reversed(parts):
if _OFFER_HINT_RE.search(part):
return part
if len(text) > 900:
return text[-900:].strip()
return text
def _prior_findings_without_offer(assistant_text: str) -> str:
"""Body of the last assistant reply minus the pending-offer paragraph."""
text = (assistant_text or "").strip()
if not text:
return ""
parts = [part.strip() for part in re.split(r"\n\s*\n", text) if part.strip()]
kept = [part for part in parts if not _OFFER_HINT_RE.search(part)]
body = "\n\n".join(kept).strip() if kept else text
if len(body) > 1200:
body = body[:1200].rstrip() + "…"
return body
def _last_assistant_content(
historical_dialogues: Optional[List["AgentMessage"]],
) -> Optional[str]:
if not historical_dialogues:
return None
# History is built as [human, assistant, human, assistant, ...].
if len(historical_dialogues) % 2 == 0:
return (historical_dialogues[-1].content or "").strip() or None
if len(historical_dialogues) >= 2:
return (historical_dialogues[-2].content or "").strip() or None
return None
def _expand_followup_confirmation(
user_input: str,
historical_dialogues: Optional[List["AgentMessage"]],
) -> str:
"""Rewrite short yes/ok confirmations into an explicit continuation task.
The stored chat message stays the user's short text; only the agent-facing
Question is expanded so the model continues the pending offer instead of
inventing a new topic.
"""
if not _is_short_confirmation(user_input):
return user_input
last_assistant = _last_assistant_content(historical_dialogues)
offer = _extract_pending_offer(last_assistant or "")
if not offer:
return user_input
prior = _prior_findings_without_offer(last_assistant or "")
prior_block = ""
if prior:
prior_block = (
"Prior findings already delivered (treat as established unless you "
"re-query the SAME fields and get a contradiction). Do NOT apologize "
"for or retract them while answering the pending offer:\n"
f"{prior}\n\n"
)
return (
"The user confirmed they want you to continue the pending next step from "
"your previous reply. Execute that investigation now. Stay on the same "
"topic, entities, time ranges, and metrics already discussed. Do not "
"start a new unrelated analysis theme or invent a broad new report. "
"Do not reopen schema discovery just to invalidate prior findings.\n\n"
f"{prior_block}"
f"Pending next step to execute:\n{offer}\n\n"
f"User confirmation: {_normalize_confirm_text(user_input)}"
)
_REPORT_REFERENCE_RE = re.compile(
r"(گزارش\s*بالا|گزارش\s*قبلی|این\s*گزارش|همان\s*گزارش|"
r"(?:the\s+)?(?:above|previous|last)\s+report|describe\s+(?:the\s+)?report|"
r"summarize\s+(?:the\s+)?report)",
re.IGNORECASE | re.UNICODE,
)
_MAX_KAMIDSO_HISTORY_MESSAGES = 16
def _format_report_for_history(report: Any) -> str:
"""Compact text form of a prior HTML or structured report for LLM history."""
if isinstance(report, dict) and "content" in report:
rtype = str(report.get("type") or "")
content = report.get("content")
if rtype == "html" and isinstance(content, str):
text = re.sub(r"<[^>]+>", " ", content)
return re.sub(r"\s+", " ", text).strip()[:4000]
if isinstance(content, dict):
report = content
elif isinstance(content, str):
return content.strip()[:4000]
if not isinstance(report, dict):
return str(report or "").strip()
title = str(report.get("title") or "Report").strip()
lines = [f"Title: {title}"]
for block in report.get("blocks") or []:
if not isinstance(block, dict):
continue
btype = str(block.get("type") or "")
if btype == "kpi":
label = block.get("label") or ""
value = block.get("value")
unit = block.get("unit") or block.get("sub") or ""
lines.append(f"- KPI {label}: {value} {unit}".strip())
elif btype == "text":
content = str(block.get("content") or block.get("text") or "").strip()
if content:
lines.append(f"- {content}")
elif btype == "table":
table_title = block.get("title") or "table"
rows = block.get("rows") or []
lines.append(f"- Table ({table_title}): {len(rows)} rows")
for row in rows[:8]:
lines.append(f" • {row}")
elif btype == "callout":
text = str(block.get("text") or block.get("content") or "").strip()
if text:
lines.append(f"- Note: {text}")
return "\n".join(lines).strip()
def _dialogues_from_kamidso_history(
history: Any,
*,
last_report: Any = None,
) -> List["AgentMessage"]:
"""Build alternating human/AI dialogues from durable Mongo messages."""
from kamidso.engine.core.agent import AgentMessage
if not isinstance(history, list):
return []
dialogues: List[AgentMessage] = []
for item in history[-_MAX_KAMIDSO_HISTORY_MESSAGES:]:
if not isinstance(item, dict):
continue
role = str(item.get("role") or "").strip().lower()
content = str(item.get("content") or "").strip()
if not content:
continue
if role in {"user", "human"}:
dialogues.append(AgentMessage(content=content))
elif role in {"assistant", "ai", "view"}:
dialogues.append(AgentMessage(content=content))
if last_report and dialogues:
report_text = _format_report_for_history(last_report)
if report_text:
# Attach to the latest assistant turn when possible.
if len(dialogues) % 2 == 0:
target = dialogues[-1]
elif len(dialogues) >= 2:
target = dialogues[-2]
else:
target = None
if target is not None and "[Previous structured report]" not in (
target.content or ""
):
target.content = (
f"{target.content}\n\n[Previous structured report]\n{report_text}"
)
return dialogues
def _expand_report_reference(
user_input: str,
historical_dialogues: Optional[List["AgentMessage"]],
last_report: Any = None,
) -> str:
"""Make 'describe the report above' explicit when a prior report exists."""
if not _REPORT_REFERENCE_RE.search(user_input or ""):
return user_input
report_text = ""
if last_report:
report_text = _format_report_for_history(last_report)
if not report_text:
last_assistant = _last_assistant_content(historical_dialogues) or ""
if "[Previous structured report]" in last_assistant:
report_text = last_assistant.split("[Previous structured report]", 1)[-1].strip()
elif last_assistant.strip():
report_text = last_assistant.strip()
if not report_text:
return user_input
return (
"The user is referring to the previous structured report in this "
"conversation. Describe/summarize that report for them in clear "
"language. Do not invent a new analysis or ask which report they mean "
"unless the previous report content is missing.\n\n"
f"Previous report:\n{report_text}\n\n"
f"User request: {user_input.strip()}"
)
def _kamidso_behavior_section(ext_info: Optional[Dict[str, Any]]) -> str:
behavior = ""
if isinstance(ext_info, dict):
behavior = str(ext_info.get("kamidso_behavior") or "").strip()
extra = f"{behavior}\n" if behavior else ""
return f"""
## Kamidso product rules
{_KAMIDSO_IDENTITY}
{extra}Never respond in Chinese. Use Persian for user-visible answers unless the user writes in
another language.
"""
def _kamidso_file_context(ext_info: Optional[Dict[str, Any]]) -> str:
"""Inject Kamidso-staged file paths when the legacy attachment flow is unused."""
if not isinstance(ext_info, dict):
return ""
paths: List[str] = []
raw_paths = ext_info.get("kamidso_resolved_file_paths")
if isinstance(raw_paths, list):
paths.extend(str(item).strip() for item in raw_paths if str(item).strip())
if not paths:
legacy = ext_info.get("file_path")
if isinstance(legacy, str) and legacy.strip():
paths.append(legacy.strip())
if not paths:
return ""
lines = "\n".join(f"- File path: {path}" for path in paths)
every = ""
if len(paths) > 1:
every = (
"- Every path listed here is attached to this turn. "
"Read each file the question needs.\n"
"- Do not reuse a prior answer that assumed only one file. "
"New files on this turn change what data is available.\n"
)
return f"""
## User Uploaded Files
{lines}
{every}- Files are already on the server. Analyze them with tools (`code_interpreter`, `execute_skill_script_file`, etc.).
- Never ask the user to paste, re-upload, or copy spreadsheet rows into chat.
"""
def _compose_react_file_context(
attachment_ctx: Any,
legacy_file_path: Optional[str],
ext_info: Optional[Dict[str, Any]],
) -> str:
"""Prefer the Kamidso multi-file block; never concatenate the singular legacy prompt."""
kamidso_files = _kamidso_file_context(ext_info).strip()
if kamidso_files:
return kamidso_files
return (build_file_context(attachment_ctx, legacy_file_path) or "").strip()
def _attached_file_count(
*,
attachment_ctx: Any,
ext_info: Optional[Dict[str, Any]],
file_path: Optional[str],
) -> int:
if isinstance(ext_info, dict):
raw_paths = ext_info.get("kamidso_resolved_file_paths")
if isinstance(raw_paths, list):
n = sum(1 for item in raw_paths if str(item).strip())
if n:
return n
legacy = ext_info.get("file_path")
if isinstance(legacy, str) and legacy.strip():
return 1
if attachment_ctx is not None:
manifests = getattr(attachment_ctx, "manifests", None) or ()
n = len(list(manifests))
if n:
return n
if file_path and str(file_path).strip():
return 1
return 0
def _skill_mode_intro(skill_source: str, file_count: int) -> str:
if skill_source == "datasource":
return (
"You are analyzing the database attached for this turn. "
"The user did not select a skill."
)
if skill_source == "file":
if file_count > 1:
return (
"You are analyzing the attached files. "
"Every listed file belongs to this turn. "
"Previous answers may be obsolete if they used only an earlier file."
)
return "You are analyzing the attached file."
return "You are executing the skill task selected by the user."
def _has_attached_files(
*,
attachment_ctx: Any,
ext_info: Optional[Dict[str, Any]],
file_path: Optional[str],
) -> bool:
if attachment_ctx is not None:
return True
if isinstance(ext_info, dict):
raw_paths = ext_info.get("kamidso_resolved_file_paths")
if isinstance(raw_paths, list) and any(str(item).strip() for item in raw_paths):
return True
legacy = ext_info.get("file_path")
if isinstance(legacy, str) and legacy.strip():
return True
return bool(file_path and str(file_path).strip())
def _this_turn_source_section(
*,
database_labels: Optional[List[str]] = None,
database_name: Optional[str] = None,
database_label: Optional[str] = None,
has_files: bool = False,
db_runtime: Optional[bool] = None,
) -> str:
"""Tell the model which source belongs to the current question."""
if db_runtime is None:
from kamidso.database_runtime.flags import database_runtime_v1_enabled
db_runtime = database_runtime_v1_enabled()
labels = [str(item).strip() for item in (database_labels or []) if str(item).strip()]
if not labels:
one = (database_label or database_name or "").strip()
if one:
labels = [one]
if not labels:
if has_files:
return """
## This turn's data source (mandatory)
File(s) are attached to this question. They are the only data source.
Do NOT call `sql_query` — it is not available and there is no database this turn.
Do NOT ask which database to use. Do NOT call `question` to pick a database.
Read the data sketch and Available Skills List. Call `load_skill` only if a
description matches; otherwise `load_file` or `code_interpreter` on the attached paths.
If a tool fails, retry with the attached file path — never fall back to asking for a database.
"""
return """
## This turn's data source (mandatory)
No file or database is attached to this question.
Do NOT open Excel/CSV paths under data/runtime, data/uploads, or leftover run folders from earlier chats.
Do not glob or list those directories. If the answer needs data, call the `question` tool and ask the user to attach a file or pick a database.
"""
listed = ", ".join(f'"{name}"' for name in labels)
many = len(labels) > 1
db_word = "databases" if many else "database"
extra = (
" Pass `source=` as the connection name to `database()`."
if many and db_runtime
else (" Pass `database` as the connection name when querying." if many else "")
)
if db_runtime:
if has_files:
return f"""
## This turn's data sources
{db_word.capitalize()} ({listed}) and file(s) are attached. Query the {db_word} inside `code_interpreter` with `database()`. Analyze files with pandas on FILE_PATH / FILES_JSON.{extra} Do not mix them unless the user asks to compare. Do not call `sql_query`.
"""
return f"""
## This turn's data source (mandatory)
The user selected {db_word} {listed} for this question. Analyze with `code_interpreter` and `database()`.{extra}
`db = database(); print(db.describe())`.
SQL: `df = db.query("SELECT …")`.
MongoDB is not SQL: `db.sample("collection", n=50)`, `db.count("collection")`,
`db.aggregate` with `$group`. Aliases like n/count are not collection fields.
sample()/find+limit is not a total.
Neo4j: `db.cypher("MATCH … RETURN count(n)")`.
Inspect schema first. Count and aggregate on the server (COUNT(*), $group,
$lookup+$group, Cypher count()). Never list thousands of rows so Python can
group or join them. Never open sqlite3, pymongo, or another database file/host.
Do NOT reopen Excel/CSV paths from earlier turns (for example Book1.xlsx under data/runtime/). Those files belong to previous questions. Only use them if the user names the file.
"""
if has_files:
return f"""
## This turn's data sources
{db_word.capitalize()} ({listed}) and file(s) are attached. Use `sql_query` for the {db_word} and `code_interpreter` for the files.{extra} Do not mix them unless the user asks to compare.
"""
return f"""
## This turn's data source (mandatory)
The user selected {db_word} {listed} for this question. Analyze with `sql_query`.{extra}
First understand structure (`schema:true`, known facts, then a few newest docs
only to see keys — that sample is not the population). Never guess a field name;
read the actual keys of that collection first.
Then count and aggregate on the server (COUNT(*), Mongo count:true, $group,
$lookup+$group, Cypher count()). Never list thousands of rows so Python can
group or join them. A 200-row find is a sample, not the population.
Do NOT reopen Excel/CSV paths from earlier turns (for example Book1.xlsx under data/runtime/). Those files belong to previous questions. Only use them if the user names the file.
"""
def _this_turn_allows_sql_query(attached_dbs: Optional[List[Any]]) -> bool:
"""A database is attached this turn (runtime or legacy sql_query)."""
return bool(attached_dbs)
def _this_turn_exposes_sql_query(attached_dbs: Optional[List[Any]]) -> bool:
from kamidso.database_runtime.flags import agent_sees_sql_query
return bool(attached_dbs) and agent_sees_sql_query()
def _this_turn_uses_db_runtime(attached_dbs: Optional[List[Any]]) -> bool:
from kamidso.database_runtime.flags import database_runtime_v1_enabled
return bool(attached_dbs) and database_runtime_v1_enabled()
def _heavy_delivery_rule(
*, sql_enabled: bool, has_files: bool, db_runtime: bool = False
) -> str:
"""Excel/CSV instructions. Fast/Off models treat 'Excel' as sql_query unless this is split."""
if sql_enabled and db_runtime:
return """7. **Heavy delivery rule (Excel / CSV download):** Query with `database()`
inside `code_interpreter`. If the user asked for Excel/CSV/spreadsheet output
(e.g. «خروجی اکسل», export, download), a text table in chat is NOT enough. You
MUST before `terminate` write the dataframe under PLOT_DIR
(e.g. ``df.to_excel(os.path.join(PLOT_DIR, "best_customers.xlsx"), index=False)``),
confirm the downloadable file was created, then ``terminate`` with a short
summary (text OK *in addition* to the file).
Never skip the file step when Excel was requested. Do not paste rows into code
and do not open op_snapshots."""
if sql_enabled:
return """7. **Heavy delivery rule (Excel / CSV download):** Explore with `sql_query`
observations as usual — do NOT call `code_interpreter` mid-exploration just to
print query JSON. If the user asked for Excel/CSV/spreadsheet output (e.g.
«خروجی اکسل», export, download), a text table in chat is NOT enough. You MUST
before `terminate`:
(1) pick the saved result ``PLOT_DIR/query_results/latest.json`` (or the
q_NNN.json under the final ranking query),
(2) in `code_interpreter` run pandas ``read_json`` + ``to_excel`` /
``to_csv`` under PLOT_DIR (e.g. ``best_customers.xlsx``),
(3) confirm the downloadable file was created, then ``terminate`` with a short
summary (text OK *in addition* to the file).
Never skip the file step when Excel was requested. Do not paste rows into code
and do not open op_snapshots."""
if has_files:
return """7. **Attached spreadsheet rule:** This turn's data is the uploaded Excel/CSV
file, not a database. Use the data sketch: `load_skill` only if a catalog skill
fits, otherwise `load_file` then `code_interpreter` (pandas) on those paths.
Do NOT call `sql_query`. Do NOT look for PLOT_DIR/query_results/latest.json
from a database query. If the user wants a downloadable Excel/CSV, write it
from the attached file."""
return """7. **Heavy delivery rule (Excel / CSV download):** No database is attached.
Do NOT call `sql_query`. If Excel/CSV output is requested, use `code_interpreter`
only after you have data from an attached file or knowledge base."""
def _sql_query_skill_catalog(*, sql_enabled: bool, db_runtime: bool = False) -> str:
if sql_enabled and db_runtime:
return """6. **code_interpreter / database()**: Query the attached database from Python.
`db = database(); db.describe()`. SQL: `df = db.query("SELECT …")`.
MongoDB is not SQL: `db.sample("name")`, `db.count("name")`,
`db.aggregate([{"$group": {"_id": "$field", "n": {"$sum": 1}}}], collection="name")`.
$group aliases (n, count) are not collection fields. Neo4j: `db.cypher("MATCH …")`.
Push JOIN / GROUP BY / COUNT / $lookup / $group to the database. Never pull
thousands of rows into pandas to join or group. Do not call `sql_query`.
Do not open sqlite3 / pymongo / another database file. If Excel/CSV was
requested, write the small aggregated frame under PLOT_DIR."""
if sql_enabled:
return """6. **sql_query**: Execute a read-only query against the selected database.
Parameters: {"sql": "SELECT ..." } or Mongo JSON.
Push JOIN / GROUP BY / COUNT / $lookup / $group to the database. Never pull
thousands of rows into Python to join or group.
`code_interpreter` only formats the small aggregated result (Jalali labels,
ratios, charts, Excel). When a database is selected this turn, do not use it
on Excel/CSV paths from earlier conversation turns.
If the user asked for Excel/CSV, finish with code_interpreter reading
PLOT_DIR/query_results/latest.json (already aggregated) and writing .xlsx/.csv."""
return """6. **sql_query**: Not available this turn (no database attached). Do not call it.
Analyze attached Excel/CSV with `load_file` then `code_interpreter` (pandas)."""
def _sql_query_full_catalog(*, sql_enabled: bool, db_runtime: bool = False) -> str:
if sql_enabled and db_runtime:
return (
"14. **code_interpreter**: Query the attached database with `database()`. "
"SQL: JOIN / GROUP BY / COUNT(*) via db.query. "
"MongoDB: never SQL — db.sample / db.count / db.aggregate $group. "
"Never dump a collection into pandas. Do not call sql_query."
)
if sql_enabled:
return (
"14. **sql_query**: Read-only query on the selected database. "
"SQL: JOIN / GROUP BY / COUNT(*) on the server. "
"MongoDB: JSON with count:true or $group / $lookup+$group. "
"Never dump a collection into Python to aggregate. "
'Parameters: {"sql": "query"}'
)
return (
"14. **sql_query**: Not available this turn (no database attached). "
"Do not call it. Use `load_file` / `code_interpreter` on attached files."
)
def _parse_attached_databases(ext_info: Optional[Dict[str, Any]]) -> List[tuple]:
if not isinstance(ext_info, dict):
return []
names = ext_info.get("database_names")
labels = ext_info.get("database_labels")
if isinstance(names, str):
try:
parsed = json.loads(names)
names = parsed if isinstance(parsed, list) else [names]
except Exception:
names = [names]
if not isinstance(names, list) or not names:
one = ext_info.get("database_name")
if not one:
return []
names = [one]
labels = [ext_info.get("database_label") or one]
if not isinstance(labels, list) or len(labels) != len(names):
labels = list(names)
if ext_info.get("database_label") and labels:
labels[0] = ext_info.get("database_label")
attached = []
for raw_id, raw_label in zip(names, labels):
ds_id = str(raw_id or "").strip()
if not ds_id:
continue
attached.append((ds_id, str(raw_label or ds_id).strip() or ds_id))
return attached
def _database_query_hint(db_type: str, dialect: str) -> str:
from kamidso.database_runtime.flags import database_runtime_v1_enabled
use_runtime = database_runtime_v1_enabled()
tool = (
"code_interpreter with database().cypher"
if use_runtime
else "sql_query"
)
if dialect == "cypher" or db_type == "neo4j":
return (
f"Use {tool} with read-only Cypher. For totals use "
"RETURN count(n), never RETURN every node. "
"Example: MATCH (u:User) RETURN count(u) AS n."
)
if db_type == "mongodb" or dialect == "mongodb":
mongo_tool = (
"database().query / database().aggregate inside code_interpreter"
if use_runtime
else "sql_query"
)
return (
f"Use {mongo_tool} with JSON, never SQL. "
"First understand structure: {\"schema\": true}; if keys are still "
"unclear, sample a few newest docs (limit at most 20), not 200. "
"Never guess a field — use a name only after it appears in schema "
"or a sample of that collection. "
'Totals: {"collection":"users","count":true,"filter":{}}. '
"Trends, rankings, and joins MUST run on the server: $group/$count "
"and $lookup+$group — never $project+$sort the whole collection into "
"Python (that times out and 200 rows is not 10k users). "
'Groups: {"collection":"users","aggregate":[{"$group":{"_id":'
'{"y":{"$year":"$created_at"},"m":{"$month":"$created_at"}},'
'"n":{"$sum":1}}}]}. '
"code_interpreter only formats the small group table (Jalali labels, "
"charts, Excel), not the raw collection. "
'Shape sample only: {"collection":"users","filter":{},"limit":20}. '
"For schema discovery sample newest docs (created_at/_id desc) and "
"list top-level keys; do not infer missing sibling fields from one "
"nested object. "
"Find is capped at 200 and is not a total count. "
"Never list documents to count a large collection."
)
sql_tool = (
"database().query inside code_interpreter"
if use_runtime
else "sql_query"
)
return (
f"Use {sql_tool} with read-only SELECT. "
"Order: inspect schema (tables/columns) → a small sample only if keys "
"are unclear → JOIN / GROUP BY / COUNT(*) / SUM on the server. "
"Example: SELECT COUNT(*) AS n FROM users. "
"Never SELECT * to count rows. Row dumps are capped and are not totals. "
"INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE are forbidden. "
"Do not ATTACH another database file or query a sibling database."
)
async def _resolve_model_context_tokens(
llm_client: Any, model_name: Optional[str]
) -> Optional[int]:
"""Resolve model context window from runtime model metadata."""
if not llm_client or not model_name:
return None
try:
metadata = await llm_client.get_model_metadata(model_name)
context_length = getattr(metadata, "context_length", None)
if isinstance(context_length, int) and context_length > 0:
return context_length
except Exception:
logger.debug(
"Failed to resolve context window for model %s", model_name, exc_info=True
)
return None
async def _load_context_budget_config(
llm_client: Any = None,
model_name: Optional[str] = None,
) -> ContextBudgetConfig:
"""Build context budget config from app TOML and model metadata."""
defaults = ContextBudgetConfig()
def _value(agent_context: Any, field_name: str, default: Any) -> Any:
if agent_context is None:
return default
value = getattr(agent_context, field_name, default)
return default if value is None else value
try:
app_config = CFG.SYSTEM_APP.config.configs.get("app_config")
web_config = getattr(getattr(app_config, "service", None), "web", None)
agent_context = getattr(web_config, "agent_context", None)
max_context_tokens = _value(
agent_context, "max_context_tokens", defaults.max_context_tokens
)
return ContextBudgetConfig(
max_context_tokens=max_context_tokens,
warning_threshold=_value(
agent_context, "warning_threshold", defaults.warning_threshold
),
error_threshold=_value(
agent_context, "error_threshold", defaults.error_threshold
),
critical_threshold=_value(
agent_context, "critical_threshold", defaults.critical_threshold
),
reserved_tokens=_value(
agent_context, "reserved_tokens", defaults.reserved_tokens
),
min_keep_recent_rounds=_value(
agent_context,
"min_keep_recent_rounds",
defaults.min_keep_recent_rounds,
),
max_compact_failures=_value(
agent_context,
"max_compact_failures",
defaults.max_compact_failures,
),
max_observation_age_rounds=_value(
agent_context,
"max_observation_age_rounds",
defaults.max_observation_age_rounds,
),
truncated_observation_max_chars=(
_value(
agent_context,
"truncated_observation_max_chars",
defaults.truncated_observation_max_chars,
)
),
min_keep_tokens=_value(
agent_context,
"min_keep_tokens",
defaults.min_keep_tokens,
),
)
except Exception:
logger.debug(
"Failed to load agent context config; using defaults", exc_info=True
)
return defaults
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 _parse_connector_ids(ext_info: Optional[Dict[str, Any]]) -> List[str]:
"""Extract connector IDs from ``ext_info``.
Supports two shapes:
- ``ext_info.connector_ids`` — a list of UUID strings (preferred).
- ``ext_info.connector_id`` — a single UUID string (legacy back-compat).
Returns an empty list when no valid IDs are found.
"""
if not ext_info or not isinstance(ext_info, dict):
return []
raw_ids = ext_info.get("connector_ids")
if isinstance(raw_ids, list):
return [cid for cid in raw_ids if isinstance(cid, str) and cid]
legacy = ext_info.get("connector_id")
if isinstance(legacy, str) and legacy:
return [legacy]
return []
def _select_connector_tools(
connector_ids: List[str],
connector_manager: Optional["ConnectorManager"],
) -> Tuple[List["BaseTool"], List[str]]:
"""Resolve connector_ids to flat list of BaseTool ready for ToolPack injection.
Internally each connector is an MCPToolPack containing multiple BaseTool
(one per MCP server tool). We flatten here so callers can directly compose
them into a parent ToolPack without nesting issues -- nested ResourcePack
would otherwise be inserted as a single dict entry under pack.name,
making prefixed tool names un-lookup-able from the outer ToolPack.
Args:
connector_ids: IDs the user selected in the frontend.
connector_manager: A :class:`ConnectorManager` instance (or ``None``).
Returns:
A tuple of ``(tools, missing_ids)`` where *tools* is the flat list
of :class:`BaseTool` objects (each one a single MCP tool with its
server URL captured in the call closure) and *missing_ids* lists
IDs that could not be resolved (e.g. deleted mid-conversation).
"""
from kamidso.engine.core.agent.resource.tool.base import BaseTool
tools: List["BaseTool"] = []
missing_ids: List[str] = []
if connector_manager is None or not connector_ids:
return tools, missing_ids
for cid in connector_ids:
pack = connector_manager.get_connector_tools(cid)
if pack is None:
missing_ids.append(cid)
continue
# Flatten: extract BaseTool instances from the pack
for sub_tool in pack.sub_resources:
if isinstance(sub_tool, BaseTool):
tools.append(sub_tool)
return tools, missing_ids
async def _execute_skill_script_impl(
skill_name: str, script_name: str, args: dict
) -> str:
"""Execute a script from a skill (implementation)."""
skill_manager = get_skill_manager(CFG.SYSTEM_APP)
result = await skill_manager.execute_script(skill_name, script_name, args)
return result
@tool(
description='Execute an inline script from a skill. Parameters: '
'{"skill_name": "skill name", "script_name": "script name", "args": {...}}'
)
async def execute_skill_script(skill_name: str, script_name: str, args: dict) -> str:
"""Execute a script from a skill."""
return await _execute_skill_script_impl(skill_name, script_name, args)
@tool(
description="Read a non-script resource from a skill (references, templates, config). "
'Parameters: {"skill_name": "skill name", "resource_path": "path inside skill"}'
'\\nExample: {"skill_name": "my-skill", '
'"resource_path": "references/analysis_framework.md"}'
"\nNote: use execute_skill_script_file for skill scripts, not shell_interpreter."
)
async def get_skill_resource(
skill_name: str, resource_path: str, args: Optional[dict] = None
) -> str:
from kamidso.engine.core.agent.skill.manage import get_skill_manager
try:
sm = get_skill_manager(CFG.SYSTEM_APP)
result = await sm.get_skill_resource(skill_name, resource_path, args or {})
return result
except Exception as e:
import json
return json.dumps(
{"error": True, "message": f"Error: {str(e)}"},
ensure_ascii=False,
)
@tool(
description="Execute a script file from a skill scripts directory. Parameters: "
'{"skill_name": "skill name", "script_file_name": "script.py", "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."""
from kamidso.engine.core.agent.skill.manage import get_skill_manager
try:
sm = get_skill_manager(CFG.SYSTEM_APP)
result = await sm.execute_skill_script_file(
skill_name, script_file_name, args or {}
)
return result
except Exception as e:
import json
return json.dumps(
{"chunks": [{"output_type": "text", "content": f"Error: {str(e)}"}]},
ensure_ascii=False,
)
@router.get("/v1/skills/list", response_model=Result)
async def list_skills(
user_token: UserRequest = Depends(get_user_from_headers),
):
"""List installed skills with product policy applied. No filesystem paths."""
from kamidso.engine.app.openapi.api_v1.tools.skill_policy import SkillPolicy
from kamidso.engine.app.openapi.api_v1.tools.skill_service import get_skill_service
try:
policy = SkillPolicy.for_request()
skills_data = []
for skill in get_skill_service().discover_installed():
payload = policy.ui_payload(
skill.skill_id,
fallback_name=skill.name,
fallback_description=skill.description,
)
payload["version"] = skill.version
payload["type"] = "official" if skill.source == "enterprise" else "personal"
payload["enabled"] = policy.is_enabled(skill.skill_id)
payload["minimum_reasoning"] = (
skill.reasoning_minimum or payload.get("reasoning_minimum") or ""
)
skills_data.append(payload)
skills_data.sort(key=lambda x: (0 if x["type"] == "official" else 1, x["id"]))
return Result.succ(skills_data)
except Exception as e:
logger.exception("Failed to load skills from directory")
return Result.failed(code="E5001", msg=f"Failed to load skills: {str(e)}")
@router.get("/v1/skills/detail", response_model=Result)
async def skill_detail(
skill_name: str = Query("", description="Skill name"),
file_path: str = Query("", description="Skill file path"),
user_token: UserRequest = Depends(get_user_from_headers),
):
"""Load a skill detail, including file tree and SKILL.md content."""
if not file_path:
return Result.failed(code="E4001", msg="file_path is required")
skills_dir = Path(DEFAULT_SKILLS_DIR).expanduser().resolve()
# Always treat file_path as relative to skills_dir.
# If an absolute path was provided (legacy), try to make it relative first.
fp = Path(file_path).expanduser()
if fp.is_absolute():
try:
fp = fp.resolve().relative_to(skills_dir)
except Exception:
return Result.failed(code="E4002", msg="Invalid skill file path")
target = (skills_dir / fp).resolve()
# Security: ensure target is under skills_dir
try:
target.relative_to(skills_dir)
except Exception:
return Result.failed(code="E4002", msg="Invalid skill file path")
if not target.exists():
return Result.failed(code="E4040", msg="Skill file not found")
root_dir = target if target.is_dir() else target.parent
def build_tree(path: Path, base: Path) -> Dict[str, Any]:
rel = path.relative_to(base)
node: Dict[str, Any] = {
"title": path.name,
"key": str(rel),
}
if path.is_dir():
children = sorted(
[p for p in path.iterdir() if not p.name.startswith(".")],
key=lambda p: (not p.is_dir(), p.name.lower()),
)
node["children"] = [build_tree(child, base) for child in children]
return node
tree = build_tree(root_dir, root_dir)
skill_md_path = root_dir / "SKILL.md"
frontmatter = ""
instructions = ""
raw_content = ""
content_type = ""
if skill_md_path.exists():
raw_content = skill_md_path.read_text(encoding="utf-8")
content_type = "skill_md"
content = raw_content.strip()
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
frontmatter = parts[1].strip()
instructions = parts[2].strip()
else:
instructions = content
else:
instructions = content
elif target.is_file():
raw_content = target.read_text(encoding="utf-8")
suffix = target.suffix.lower()
if suffix in {".yaml", ".yml"}:
content_type = "yaml"
frontmatter = raw_content
elif suffix == ".json":
content_type = "json"
frontmatter = raw_content
else:
content_type = "text"
instructions = raw_content
metadata: Dict[str, Any] = {}
try:
from kamidso.engine.core.agent.skill.loader import SkillLoader
loader = SkillLoader()
skill = loader.load_skill_from_file(str(target))
if skill and getattr(skill, "metadata", None):
try:
metadata = skill.metadata.to_dict() # type: ignore[attr-defined]
except Exception:
metadata = {
"name": getattr(skill.metadata, "name", ""),
"description": getattr(skill.metadata, "description", ""),
"version": getattr(skill.metadata, "version", ""),
"author": getattr(skill.metadata, "author", ""),
"skill_type": getattr(skill.metadata, "skill_type", ""),
"tags": getattr(skill.metadata, "tags", []) or [],
}
except Exception:
metadata = {}
if not frontmatter and metadata:
frontmatter = "\n".join(
[
f"name: {metadata.get('name', '')}",
f"description: {metadata.get('description', '')}",
f"version: {metadata.get('version', '')}",
f"author: {metadata.get('author', '')}",
f"skill_type: {metadata.get('skill_type', '')}",
]
).strip()
display_path = str(target)
display_root = str(root_dir)
try:
display_path = str(target.relative_to(skills_dir))
display_root = str(root_dir.relative_to(skills_dir))
except Exception:
pass
return Result.succ(
{
"skill_name": skill_name or metadata.get("name", ""),
"file_path": display_path,
"root_dir": display_root,
"tree": tree,
"frontmatter": frontmatter,
"instructions": instructions,
"raw_content": raw_content,
"content_type": content_type,
"metadata": metadata,
}
)
def _install_skill_from_dir(src_dir: Path, skill_name: str, user_dir: Path) -> str:
"""Copy an extracted skill directory into the user skills directory.
Args:
src_dir (Path): Directory containing the skill's files (already extracted).
skill_name (str): Name to use for the skill directory under ``user_dir``.
user_dir (Path): The ``skills/user/`` directory.
Returns:
str: Path of the installed skill directory relative to the skills root
(i.e. ``user/<skill_name>``).
"""
from kamidso.engine.app.openapi.api_v1.tools.skill_policy import PROTECTED_SKILL_IDS
from kamidso.engine.app.openapi.api_v1.tools.skill_service import is_safe_skill_id
if not is_safe_skill_id(skill_name) or skill_name in PROTECTED_SKILL_IDS:
raise ValueError(f"reject collision: '{skill_name}' is reserved")
dest = user_dir / skill_name
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(src_dir, dest)
# Return path relative to skills_dir (parent of user_dir)
return dest.relative_to(user_dir.parent).as_posix()
@router.post("/v1/skills/upload", response_model=Result)
async def skill_upload(
file: UploadFile = File(...),
user_token: UserRequest = Depends(get_user_from_headers),
):
"""Upload a skill package (.zip, .skill) or a single file to pilot/tmp/."""
if not file.filename:
return Result.failed(code="E4001", msg="No file provided")
upload_dir = Path(resolve_root_path("pilot/tmp") or "pilot/tmp").resolve()
upload_dir.mkdir(parents=True, exist_ok=True)
skills_dir = Path(DEFAULT_SKILLS_DIR).expanduser().resolve()
user_dir = skills_dir / "user"
user_dir.mkdir(parents=True, exist_ok=True)
filename = file.filename
from kamidso.engine.app.openapi.api_v1.tools.skill_policy import PROTECTED_SKILL_IDS
from kamidso.engine.app.openapi.api_v1.tools.skill_service import (
get_skill_service,
is_safe_upload_filename,
)
if not is_safe_upload_filename(filename):
return Result.failed(code="E4002", msg="Invalid skill file path")
suffix = Path(filename).suffix.lower()
stem = Path(filename).stem
if stem in PROTECTED_SKILL_IDS:
return Result.failed(
code="E4090",
msg=f"reject collision: '{stem}' is an enterprise skill id",
)
try:
content_bytes = await file.read()
tmp_file = upload_dir / Path(filename).name
tmp_file.write_bytes(content_bytes)
is_archive = False
if suffix == ".zip":
is_archive = True
elif suffix == ".skill":
buf = io.BytesIO(content_bytes)
is_archive = zipfile.is_zipfile(buf)
if is_archive:
# Reuse the robust _extract_skill_from_zip helper (same one used
# by the GitHub import endpoint) to avoid the nested-directory bug
# that the old inline extractall logic suffered from.
#
# strict=False: uploaded packages may not contain a SKILL.md yet.
tmp_zip = upload_dir / f"{uuid.uuid4().hex}.zip"
tmp_zip.write_bytes(content_bytes)
try:
with tempfile.TemporaryDirectory(dir=upload_dir) as tmp_extract:
dest_in_tmp = Path(tmp_extract) / "skill"
try:
dest_name = _extract_skill_from_zip(
tmp_zip, subpath=None, dest_dir=dest_in_tmp, strict=False
)
except ValueError as exc:
return Result.failed(code="E4002", msg=str(exc))
rel_path = _install_skill_from_dir(dest_in_tmp, dest_name, user_dir)
finally:
tmp_zip.unlink(missing_ok=True)
else:
dest = user_dir / stem
dest.mkdir(parents=True, exist_ok=True)
if suffix in (".md", ".skill"):
target_name = "SKILL.md"
else:
target_name = filename
target_file = dest / target_name
target_file.write_bytes(content_bytes)
rel_path = dest.relative_to(skills_dir).as_posix()
get_skill_service().invalidate()
return Result.succ(
{
"file_path": rel_path,
"tmp_path": str(tmp_file),
"message": f"Skill uploaded successfully: {rel_path}",
}
)
except ValueError as e:
return Result.failed(code="E4090", msg=str(e))
except Exception as e:
logger.exception("Failed to upload skill")
return Result.failed(code="E5002", msg=f"Upload failed: {str(e)}")
def _parse_github_url(
github_url: str,
) -> "tuple[str, str, str, Optional[str]]":
"""Parse a GitHub or skills.sh URL into (owner, repo, branch, subdir).
Supported formats:
- https://github.com/owner/repo
- https://github.com/owner/repo/tree/<branch>[/optional/sub/dir]
- https://github.com/owner/repo/blob/<branch>/path/to/FILE.md
- https://skills.sh/owner/repo
- https://skills.sh/owner/repo[/skill-name]
Returns:
tuple[str, str, str, Optional[str]]
(owner, repo, branch, subdir) — branch is always a str (defaults to "main")
Raises:
ValueError: if the URL is not a recognisable GitHub/skills.sh repo URL.
"""
parsed = urlparse(github_url)
is_skills_sh = parsed.netloc in ("skills.sh", "www.skills.sh")
is_github = parsed.netloc in ("github.com", "www.github.com")
if not is_github and not is_skills_sh:
raise ValueError(f"Not a GitHub URL: {github_url!r}")
parts = [p for p in parsed.path.strip("/").split("/") if p]
if len(parts) < 2:
raise ValueError(f"Cannot extract owner/repo from URL: {github_url!r}")
owner, repo = parts[0], parts[1]
# Strip '.git' suffix if present
if repo.endswith(".git"):
repo = repo[:-4]
branch: str = "main"
subdir: Optional[str] = None
if is_skills_sh:
# skills.sh: /owner/repo[/skill-name[/more]]
# Everything after owner/repo is treated as subpath
if len(parts) >= 3:
subdir = "/".join(parts[2:])
else:
# GitHub
if len(parts) >= 4 and parts[2] == "tree":
# /owner/repo/tree/<branch>[/path/to/subdir]
branch = parts[3]
if len(parts) >= 5:
subdir = "/".join(parts[4:])
elif len(parts) >= 4 and parts[2] == "blob":
# /owner/repo/blob/<branch>/path/to/FILE.md — strip filename
branch = parts[3]
if len(parts) >= 6:
# Keep everything except the last component (the filename)
subdir = "/".join(parts[4:-1])
# If exactly 5 parts: blob/<branch>/filename — no subdir
return owner, repo, branch, subdir
def _construct_download_url(owner: str, repo: str, branch: str) -> str:
"""Return the GitHub archive ZIP download URL for the given branch.
Args:
owner (str): Repository owner/organisation.
repo (str): Repository name.
branch (str): Branch name.
Returns:
str: URL pointing to the ZIP archive for the branch.
"""
return f"https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip"
def _is_macos_junk(name: str) -> bool:
"""Return True if the archive entry is a macOS metadata artifact."""
parts = name.split("/")
return any(p == "__MACOSX" or p.startswith("._") for p in parts)
def _extract_skill_from_zip(
zip_path: "Path",
subpath: "Optional[str]",
dest_dir: "Path",
strict: bool = True,
) -> str:
"""Extract a skill from a ZIP archive into ``dest_dir``.
The ZIP is expected to have a single top-level directory (e.g.
``repo-main/``). That top-level directory is stripped when extracting so
that the files inside it land directly in ``dest_dir``.
When ``subpath`` is given, only the files under
``{top_dir}/{subpath}/`` are extracted (again, stripped to ``dest_dir``).
macOS metadata entries (``__MACOSX/`` directories and ``._*`` files) are
automatically filtered out before any directory-structure analysis so they
do not cause spurious nested directories.
Args:
zip_path (Path): Path to the ZIP file on disk.
subpath (Optional[str]): Relative sub-directory inside the archive
(after stripping the top-level dir) that contains the skill.
Pass ``None`` to use the root of the archive.
dest_dir (Path): Directory into which the skill files are extracted.
It is created if it does not exist; if it already exists its
contents are removed before extraction.
strict (bool): When ``True`` (default), raise ``ValueError`` if no
``SKILL.md`` is found in the archive. When ``False``, skip the
``SKILL.md`` validation — useful for uploading generic skill
packages that may not yet contain a ``SKILL.md``.
Returns:
str: The skill name derived from ``subpath`` (last component) or from
the top-level archive directory name.
Raises:
ValueError: If the archive contains path-traversal sequences.
ValueError: If no ``SKILL.md`` is found after extraction (only when
``strict=True``).
ValueError: If the archive root contains multiple sub-directories with
``SKILL.md`` files and no ``subpath`` was specified (the error
message lists the available sub-directory names).
"""
with zipfile.ZipFile(zip_path, "r") as zf:
all_names = zf.namelist()
# Security: reject any path-traversal entries
for name in all_names:
normalized = os.path.normpath(name)
if normalized.startswith("..") or ".." in normalized.split(os.sep):
raise ValueError(f"Unsafe path in archive: {name!r}")
# Filter out macOS metadata artifacts before analysing structure
valid_names = [n for n in all_names if not _is_macos_junk(n)]
# Detect the single top-level directory (GitHub archives always have one)
top_dirs = {n.split("/")[0] for n in valid_names if "/" in n}
archive_root: Optional[str] = top_dirs.pop() if len(top_dirs) == 1 else None
# Build the prefix inside the archive that maps to dest_dir
if subpath:
skill_prefix = (
f"{archive_root}/{subpath}/" if archive_root else f"{subpath}/"
)
skill_name = subpath.split("/")[-1]
else:
skill_prefix = f"{archive_root}/" if archive_root else ""
skill_name = archive_root or dest_dir.name
# Check whether SKILL.md exists under the chosen prefix
skill_md_entry = next(
(n for n in valid_names if n == skill_prefix + "SKILL.md"),
None,
)
if skill_md_entry is None and not subpath:
# No SKILL.md at root — scan one level of subdirectories
subdirs_with_skill = []
for name in valid_names:
if not name.startswith(skill_prefix):
continue
rel = name[len(skill_prefix) :]
parts = rel.split("/")
if len(parts) == 2 and parts[1] == "SKILL.md":
subdirs_with_skill.append(parts[0])
if len(subdirs_with_skill) > 1:
raise ValueError(
"Multiple skills found. Specify a subpath. "
"Available: " + ", ".join(sorted(subdirs_with_skill))
)
# If exactly one sub-directory has SKILL.md, use it automatically
if len(subdirs_with_skill) == 1:
only_subdir = subdirs_with_skill[0]
skill_prefix = f"{skill_prefix}{only_subdir}/"
skill_name = only_subdir
skill_md_entry = skill_prefix + "SKILL.md"
if strict and skill_md_entry is None:
raise ValueError(
"No SKILL.md found in the archive"
+ (f" under '{subpath}'" if subpath else "")
+ ". Make sure the skill directory contains a SKILL.md file."
)
# Prepare dest_dir: remove existing content then (re-)create
if dest_dir.exists():
shutil.rmtree(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
# Extract valid members individually (no extractall) for security
for member in valid_names:
if not member.startswith(skill_prefix) or member == skill_prefix:
continue
rel = member[len(skill_prefix) :]
if not rel:
continue
target = dest_dir / rel
if member.endswith("/"):
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(zf.read(member))
return skill_name
@router.post("/v1/skills/import_github", response_model=Result)
async def skill_import_from_github_v2(
request: Request,
user_token: UserRequest = Depends(get_user_from_headers),
):
"""Import a skill from a GitHub or skills.sh URL.
Accepts ``{ "url": "..." }`` from the frontend, downloads the repository
ZIP, extracts the skill, installs it to ``skills/user/<name>/``, and
returns a success response.
This endpoint:
- Accepts a raw JSON body ``{ "url": "..." }`` (no Pydantic model).
- Supports branch fallback: tries ``main`` first, then ``master`` if 404.
- Enforces a 50 MB download size limit.
- Delegates extraction/installation to the modular helpers
``_extract_skill_from_zip`` and ``_install_skill_from_dir``.
Error codes:
- ``E4001``: Empty URL.
- ``E4003``: Malformed or non-GitHub/skills.sh URL.
- ``E4004``: ``SKILL.md`` not found in the downloaded content.
- ``E4005``: Download failed or size limit exceeded.
- ``E5002``: Unexpected server-side error.
"""
import httpx
# --- parse JSON body --------------------------------------------------------
body = await request.json()
url = body.get("url", "").strip()
if not url:
return Result.failed(code="E4001", msg="URL must not be empty")
# --- parse URL --------------------------------------------------------------
try:
owner, repo, branch, subpath = _parse_github_url(url)
except ValueError as exc:
return Result.failed(code="E4003", msg=str(exc))
# --- resolve dirs -----------------------------------------------------------
skills_dir = Path(DEFAULT_SKILLS_DIR).expanduser().resolve()
user_dir = skills_dir / "user"
user_dir.mkdir(parents=True, exist_ok=True)
upload_dir = Path(resolve_root_path("pilot/tmp") or "pilot/tmp").resolve()
upload_dir.mkdir(parents=True, exist_ok=True)
# --- download with branch fallback (main → master) --------------------------
zip_path: Optional[Path] = None
tmp_dir_obj = None # tempfile.TemporaryDirectory kept alive until finally
try:
zip_url = _construct_download_url(owner, repo, branch)
async with httpx.AsyncClient(
follow_redirects=True,
timeout=httpx.Timeout(120.0),
) as client:
response = await client.get(zip_url)
# Branch fallback: if the resolved branch gives 404, try "master"
if response.status_code == 404 and branch == "main":
fallback_branch = "master"
fallback_url = _construct_download_url(owner, repo, fallback_branch)
response = await client.get(fallback_url)
if response.status_code == 200:
branch = fallback_branch
zip_url = fallback_url
if response.status_code != 200:
return Result.failed(
code="E4005",
msg=(
f"Failed to download {zip_url!r}: HTTP {response.status_code}"
),
)
content_bytes = response.content
# --- size limit check ---------------------------------------------------
if len(content_bytes) > 50 * 1024 * 1024:
return Result.failed(
code="E4005",
msg=(
f"Download size {len(content_bytes) // (1024 * 1024)} MB "
"exceeds the 50 MB limit"
),
)
# --- save raw zip to tmp ------------------------------------------------
zip_filename = f"{repo}-{branch}.zip"
zip_path = upload_dir / zip_filename
zip_path.write_bytes(content_bytes)
# --- extract into a temp directory, then install ------------------------
tmp_dir_obj = tempfile.TemporaryDirectory(dir=upload_dir)
dest_dir_in_temp = Path(tmp_dir_obj.name) / "skill"
dest_dir_in_temp.mkdir(parents=True, exist_ok=True)
try:
skill_name = _extract_skill_from_zip(zip_path, subpath, dest_dir_in_temp)
except ValueError as exc:
err_msg = str(exc)
if "SKILL.md" in err_msg:
return Result.failed(code="E4004", msg=err_msg)
return Result.failed(code="E4003", msg=err_msg)
rel_path = _install_skill_from_dir(dest_dir_in_temp, skill_name, user_dir)
from kamidso.engine.app.openapi.api_v1.tools.skill_service import (
get_skill_service,
)
get_skill_service().invalidate()
return Result.succ(
{
"file_path": rel_path,
"message": f"Skill imported successfully from GitHub: {rel_path}",
}
)
except httpx.RequestError as exc:
logger.exception("Network error while downloading skill from GitHub")
return Result.failed(
code="E4005", msg=f"Network error downloading skill: {str(exc)}"
)
except ValueError as exc:
return Result.failed(code="E4090", msg=str(exc))
except Exception as exc:
logger.exception("Failed to import skill from GitHub (v2)")
return Result.failed(code="E5002", msg=f"Import failed: {str(exc)}")
finally:
# Clean up temp zip file
if zip_path is not None:
try:
zip_path.unlink(missing_ok=True)
except Exception:
pass
# Clean up temp extraction directory
if tmp_dir_obj is not None:
try:
tmp_dir_obj.cleanup()
except Exception:
pass
def _sse_event(payload: Dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
def _build_react_history_payload(
*,
final_content: str,
steps: List[Dict[str, Any]],
task_plan: List[Dict[str, Any]],
generated_images: List[Any],
sub_agents: Any,
input_files: List[Dict[str, Any]],
citations: Optional[List[Dict[str, Any]]] = None,
) -> str:
"""Serialize the persisted react-agent history payload (version 2).
The success and error paths share this builder so both persist the same
shape. ``input_files`` is the current turn's public snapshot produced by
:func:`build_input_files_v2` — safe metadata only, never server paths,
storage URIs, owner ids, hashes or inspection bodies. ``citations`` comes
from the final-answer assembler and remains display-safe metadata.
"""
return json.dumps(
{
"version": 2,
"protocol_version": 2,
"type": "react-agent",
"final_content": final_content,
"citations": citations or [],
"steps": steps,
"task_plan": task_plan,
"generated_images": generated_images,
"sub_agents": sub_agents,
"input_files": input_files,
},
ensure_ascii=False,
)
def _sse_event_type(event: Any) -> Optional[str]:
"""Read an SSE event type without trusting arbitrary streamed text."""
if not isinstance(event, str):
return None
first_line = event.splitlines()[0] if event else ""
if not first_line.startswith("data:"):
return None
try:
payload = json.loads(first_line.removeprefix("data:").strip())
except (json.JSONDecodeError, TypeError, ValueError):
return None
event_type = payload.get("type") if isinstance(payload, dict) else None
return event_type if isinstance(event_type, str) else None
def _react_terminal_events(
storage_conv: Any,
history_payload: str,
final_answer: AgentFinalAnswer,
) -> Tuple[str, str]:
"""Persist one ReAct round without risking its terminal SSE events."""
try:
storage_conv.add_view_message(history_payload)
storage_conv.end_current_round()
storage_conv.save_to_storage()
except Exception:
logger.exception("Failed to persist ReAct agent history")
return (
_sse_event(final_answer.to_sse_payload()),
_sse_event({"type": "done"}),
)
async def _cancel_and_await_agent_task(task: "asyncio.Task[Any]") -> None:
"""Cancel a running agent task and always consume its terminal result."""
was_done = task.done()
if not was_done:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception:
if not was_done:
logger.exception("ReAct agent task failed during stream cleanup")
async def _react_agent_stream(
dialogue: ConversationVo,
tool_mode: str = "full",
attachment_ctx: Optional[SessionAttachmentContext] = None,
) -> AsyncGenerator[str, None]:
"""Stream the ReAct agent turn, owning the attachment lifecycle.
The resolved session-file attachment context (when ``file_ids`` were
supplied) stays open for the whole turn and is closed exactly once when
the stream completes, fails, or is closed early by the client.
Args:
dialogue: Conversation parameters (user input, model, ext_info, etc.).
tool_mode: "full" (default) — all tools; "knowledge" — kb tools only.
attachment_ctx: Pre-resolved attachment context for this turn (or
``None`` for pure-text / legacy ``file_path`` requests).
"""
stream = _react_agent_stream_inner(dialogue, tool_mode, attachment_ctx)
try:
async for event in stream:
yield event
finally:
close = getattr(stream, "aclose", None)
if callable(close):
try:
await close()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Failed to close ReAct agent inner stream")
if attachment_ctx is not None:
try:
attachment_ctx.close()
except Exception:
logger.warning(
"Failed to close session attachment context", exc_info=True
)
def _legacy_upload_base_dir() -> str:
"""Return the unified data root (uploads/runtime live under this directory)."""
root = os.environ.get("KAMIDSO_DATA_ROOT")
if root:
return root
app = CFG.SYSTEM_APP
work_dir = getattr(app, "work_dir", None) if app else None
return work_dir or os.getcwd()
async def _open_turn_attachments(
dialogue: ConversationVo, user_token: Optional[UserRequest]
) -> Optional[SessionAttachmentContext]:
"""Validate file input and resolve session attachments before streaming.
Conflicting/malformed/too-many inputs raise 400; any unresolvable
``file_id`` raises one indistinguishable, non-enumerating 404. Errors
always surface before the SSE stream (and the agent) is constructed.
"""
owner_id = (user_token.user_id if user_token else None) or dialogue.user_name
try:
attachment_ctx = await prepare_react_attachments(dialogue, owner_id=owner_id)
if attachment_ctx is None:
# Legacy ``ext_info.file_path`` requests are confined to the
# authenticated owner's uploads / runtime tree; invalid
# input raises the same 400 and ownership failures the same
# non-enumerating 404 as the file_ids flow.
try:
spec = dialogue.file_input_spec()
except Exception:
spec = None
legacy_path = spec.file_path if spec is not None else None
if legacy_path:
resolved = await run_in_threadpool(
lambda: resolve_legacy_chat_file_path(
file_path=legacy_path,
owner_id=owner_id,
base_dir=_legacy_upload_base_dir(),
)
)
dialogue.ext_info = dict(dialogue.ext_info or {})
dialogue.ext_info["file_path"] = resolved
return attachment_ctx
except AttachmentInputError as error:
raise HTTPException(
status_code=error.status_code, detail=error.message
) from error
def _close_turn_attachments_quietly(
attachment_ctx: Optional[SessionAttachmentContext],
) -> None:
"""Close the turn attachment context, logging instead of raising."""
if attachment_ctx is None:
return
try:
attachment_ctx.close()
except Exception:
logger.warning("Failed to close session attachment context", exc_info=True)
def _knowledge_space_has_code_graph(knowledge_space_id: str) -> bool:
"""Return True when the knowledge space has a built code-graph index.
Used to conditionally expose codegraph tools (kb_codegraph_*) to the
agent. True only when the graph meta record exists and has a non-zero
vertex count. Sync DAO — call via ``run_in_threadpool``.
"""
if not knowledge_space_id:
return False
try:
from kamidso.engine.serve.rag.models.code_graph_db import CodeGraphMetaDao
from kamidso.engine.serve.rag.tools.kb_file_tools import _resolve_space_name
space_name = _resolve_space_name(knowledge_space_id)
meta = CodeGraphMetaDao().get_by_knowledge_id(space_name)
return bool(meta and (meta.vertex_count or 0) > 0)
except Exception as e:
logger.warning(
"Failed to check code graph status for %s: %s", knowledge_space_id, e
)
return False
async def _react_agent_stream_inner(
dialogue: ConversationVo,
tool_mode: str = "full",
attachment_ctx: Optional[SessionAttachmentContext] = None,
) -> AsyncGenerator[str, None]:
"""Stream ReAct events while owning the lifetime of its background task."""
conv_id = getattr(dialogue, "conv_uid", None) or ""
# Same conversation is serialized by this lock. A second message waits on
# the same lock object; the lock is removed only after every waiter exits.
# Explicit cancel uses _cancel_conversation_agent.
conv_lock = await acquire_conversation_run(conv_id) if conv_id else None
try:
agent_task_holder: List["asyncio.Task[Any]"] = []
final_emitted = False
done_emitted = False
from kamidso.engine.app.openapi.api_v1.tools.skill_runtime import (
bind_shared_execution,
empty_skill_execution,
reset_shared_execution,
)
from kamidso.engine.core.reasoning_policy import bind_turn, reset_turn
ext_info_raw = getattr(dialogue, "ext_info", None)
ext_info = ext_info_raw if isinstance(ext_info_raw, dict) else {}
reasoning_token = bind_turn(ext_info.get("reasoning_mode") or "fast")
execution_token = bind_shared_execution(empty_skill_execution())
try:
async for event in _react_agent_stream_impl(
dialogue,
tool_mode=tool_mode,
attachment_ctx=attachment_ctx,
agent_task_holder=agent_task_holder,
):
event_type = _sse_event_type(event)
final_emitted = final_emitted or event_type == "final"
done_emitted = done_emitted or event_type == "done"
yield event
except Exception:
logger.exception("ReAct agent stream failed before normal completion")
if not final_emitted and not done_emitted:
yield _sse_event(
AgentFinalAnswer(
content=_stream_failure_user_message(ext_info)
).to_sse_payload()
)
if not done_emitted:
yield _sse_event({"type": "done"})
finally:
try:
reset_turn(reasoning_token)
except (ValueError, LookupError):
logger.debug("reasoning turn token already reset", exc_info=True)
try:
reset_shared_execution(execution_token)
except (ValueError, LookupError):
logger.debug("skill execution token already reset", exc_info=True)
if agent_task_holder:
task = agent_task_holder[0]
if conv_id and REACT_CONVERSATION_AGENT_TASKS.get(conv_id) is task:
REACT_CONVERSATION_AGENT_TASKS.pop(conv_id, None)
await _cancel_and_await_agent_task(task)
finally:
# History persistence runs inside the stream body, before this
# finally. Scratch memory is dropped while the lock is still held.
await end_react_turn(conv_id, conv_lock)
class _AgentStreamingResponse(StreamingResponse):
"""Streaming response that explicitly closes its owned body iterator."""
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
try:
await super().__call__(scope, receive, send)
finally:
close = getattr(self.body_iterator, "aclose", None)
if callable(close):
try:
await close()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Failed to close ReAct agent stream iterator")
async def _react_agent_stream_impl(
dialogue: ConversationVo,
tool_mode: str = "full",
attachment_ctx: Optional[SessionAttachmentContext] = None,
agent_task_holder: Optional[List["asyncio.Task[Any]"]] = None,
) -> AsyncGenerator[str, None]:
"""Core ReAct agent streaming logic.
Args:
dialogue: Conversation parameters (user input, model, ext_info, etc.).
tool_mode: "full" (default) — all tools (skills, shell, sql, html, code, kb...).
"knowledge" — only knowledge base tools + todowrite + terminate,
optimized for pure knowledge-chat scenarios.
attachment_ctx: Pre-resolved session attachment context for this turn.
"""
from kamidso.engine.core.agent import AgentContext, AgentMessage
from kamidso.engine.core.agent.expand.actions.react_action import Terminate
from kamidso.engine.core.agent.expand.tool_calling_agent import ToolCallingReActAgent
from kamidso.engine.core.agent.resource import ToolPack
from kamidso.engine.core.agent.resource.manage import get_resource_manager
from kamidso.engine.core.agent.util.llm.llm import LLMConfig, LLMStrategyType
from kamidso.engine.core.agent.util.react_parser import ReActOutputParser
from kamidso.engine.core.core import StorageConversation
from kamidso.engine.core.model.cluster.client import DefaultLLMClient
from kamidso.engine.serve.conversation.serve import Serve as ConversationServe
step = 0
user_input = dialogue.user_input
if not isinstance(user_input, str):
user_input = str(user_input or "")
file_path = None
knowledge_space = None
skill_name = None
database_name = None
if dialogue.ext_info and isinstance(dialogue.ext_info, dict):
file_path = dialogue.ext_info.get("file_path")
skill_name = dialogue.ext_info.get("skill_name")
# Support multiple field names for knowledge space
knowledge_space = (
dialogue.ext_info.get("knowledge_space")
or dialogue.ext_info.get("knowledge_space_name")
or dialogue.ext_info.get("knowledge_space_id")
)
database_name = dialogue.ext_info.get("database_name")
attached_dbs = _parse_attached_databases(
dialogue.ext_info if isinstance(dialogue.ext_info, dict) else None
)
if attached_dbs and not database_name:
database_name = attached_dbs[0][0]
# Connector selection (Task C): only inject user-selected connectors.
connector_ids: List[str] = _parse_connector_ids(dialogue.ext_info)
mcp_connector_ids = list(connector_ids)
native_connector_instances: List[Dict[str, Any]] = []
try:
from kamidso.native_connectors.flags import native_connectors_v1_enabled
from kamidso.native_connectors.resolve import partition_connector_ids
if native_connectors_v1_enabled() and connector_ids:
mcp_connector_ids, native_connector_instances = partition_connector_ids(
connector_ids
)
except Exception:
logger.exception("native connector partition failed")
mcp_connector_ids = list(connector_ids)
native_connector_instances = []
code_graph_available = False
if knowledge_space:
code_graph_available = await run_in_threadpool(
_knowledge_space_has_code_graph, knowledge_space
)
thinking_step_title = "Thinking"
if isinstance(getattr(dialogue, "ext_info", None), dict):
response_language = str(dialogue.ext_info.get("response_language") or "")
if response_language.startswith("fa"):
thinking_step_title = "در حال تحلیل"
def build_step(title: str, detail: str, phase: str = None, **extra):
nonlocal step
step += 1
step_id = f"step-{step}"
event_data = {
"type": "step.start",
"step": step,
"id": step_id,
"title": title,
"detail": detail,
}
if phase:
event_data["phase"] = phase
for key, value in extra.items():
if value is not None:
event_data[key] = value
return step_id, _sse_event(event_data)
def step_output(detail: str):
return _sse_event({"type": "step.output", "step": step, "detail": detail})
def step_chunk(step_id: str, output_type: str, content: Any):
return _sse_event(
{
"type": "step.chunk",
"id": step_id,
"output_type": output_type,
"content": content,
}
)
def step_done(step_id: str, status: str = "done"):
return _sse_event({"type": "step.done", "id": step_id, "status": status})
def step_meta(
step_id: str,
thought: Optional[str],
action: Optional[str],
action_input: Optional[str],
title: Optional[str] = None,
action_intention: Optional[str] = None,
action_reason: Optional[str] = None,
todo_meta: Optional[Dict[str, Any]] = None,
):
payload = {
"type": "step.meta",
"id": step_id,
"thought": thought,
"action_intention": action_intention,
"action_reason": action_reason,
"action": action,
"action_input": action_input,
"title": title,
}
if todo_meta:
payload["todo_meta"] = todo_meta
return _sse_event(payload)
def chunk_text(text: str, max_len: int = 800) -> List[str]:
if not text:
return []
chunks: List[str] = []
start = 0
while start < len(text):
chunks.append(text[start : start + max_len])
start += max_len
return chunks
def emit_tool_chunks(step_id: str, content: Any) -> List[str]:
raw_chunks: List[str] = []
if content is None:
return raw_chunks
parsed = None
if isinstance(content, str):
try:
parsed = json.loads(content)
except Exception:
parsed = None
if isinstance(parsed, dict) and isinstance(parsed.get("chunks"), list):
for item in parsed["chunks"]:
if not isinstance(item, dict):
continue
output_type = item.get("output_type") or "text"
payload = item.get("content")
if output_type in ["code", "markdown"] and isinstance(payload, str):
# Send code and markdown as a single chunk — never split it.
raw_chunks.append(step_chunk(step_id, output_type, payload))
elif output_type in ["text"] and isinstance(payload, str):
for chunk in chunk_text(payload, max_len=800):
raw_chunks.append(step_chunk(step_id, output_type, chunk))
else:
raw_chunks.append(step_chunk(step_id, output_type, payload))
return raw_chunks
if isinstance(content, str) and content:
for chunk in chunk_text(content, max_len=800):
raw_chunks.append(step_chunk(step_id, "text", chunk))
return raw_chunks
def normalize_display_text(value: Optional[str]) -> Optional[str]:
"""Normalize a model-provided display field."""
if not value:
return None
text = re.sub(r"\s+", " ", value).strip()
text = re.sub(
r"^(phase|status|状态|action\s+intention|action\s+reason)\s*:\s*",
"",
text,
flags=re.I,
).strip()
text = text.strip(" .,:;,。;:")
if not text:
return None
return text
def summarize_thought(
thought: Optional[str], action: Optional[str] = None
) -> Optional[str]:
"""Fallback compressor when the model does not provide a short status."""
if not thought:
return None
text = re.sub(r"\s+", " ", thought).strip()
text = re.sub(r"^(thought|phase)\s*:\s*", "", text, flags=re.I).strip()
if not text:
return None
split_markers = [
r"\baction\b\s*:",
r"\bobservation\b\s*:",
r"\bphase\b\s*:",
r"\bnow i need to\b",
r"\bnext,?\b",
r"\bthen\b",
r"现在需要",
r"下一步",
r"接下来",
r"然后",
]
marker_pattern = "|".join(split_markers)
text = re.split(marker_pattern, text, maxsplit=1, flags=re.I)[0].strip(
" .,:;,。;:"
)
prefixes = [
"the user wants me to ",
"i need to ",
"i should ",
"let me ",
"i will ",
"现在我需要",
"我需要",
"接下来我需要",
"让我",
"现在开始",
"好的,",
"好,",
]
lowered = text.lower()
for prefix in prefixes:
if lowered.startswith(prefix.lower()):
text = text[len(prefix) :].strip(" .,:;,。;:")
lowered = text.lower()
break
action_lower = (action or "").lower()
if action_lower == "sql_query":
return "正在查询数据库信息"
if action_lower == "code_interpreter":
return "正在生成分析代码"
if action_lower == "html_interpreter":
return "در حال ساخت گزارش HTML"
if action_lower == "todowrite":
return "正在更新任务计划"
if action_lower in {"execute_skill_script", "execute_skill_script_file"}:
return "正在执行分析脚本"
return text
skills_dir = DEFAULT_SKILLS_DIR
from kamidso.engine.app.openapi.api_v1.tools.skill_flags import (
enable_skill_extensions,
use_native_dbgpt_skills,
)
from kamidso.engine.app.openapi.api_v1.tools.skill_service import (
SkillLoadError,
get_skill_service,
)
skill_service = get_skill_service()
organization_id = None
if dialogue.ext_info and isinstance(dialogue.ext_info, dict):
organization_id = dialogue.ext_info.get("organization_id")
if organization_id:
organization_id = str(organization_id)
# Step 2: Get business tools from ResourceManager
rm = get_resource_manager(CFG.SYSTEM_APP)
business_tools: List[Any] = []
try:
# Get all registered tool resources from ResourceManager
tool_resources = rm._type_to_resources.get("tool", [])
for reg_resource in tool_resources:
if reg_resource.resource_instance is not None:
business_tools.append(reg_resource.resource_instance)
except Exception:
pass # If no business tools, continue with empty list
_graph_workspace = ""
if isinstance(dialogue.ext_info, dict):
_graph_workspace = str(dialogue.ext_info.get("workspace_id") or "")
if _graph_workspace:
try:
from kamidso.data_graph.tools.graph_search import organization_graph_tool
business_tools.append(organization_graph_tool(_graph_workspace))
from kamidso.data_graph.tools.semantic_inspection import organization_semantic_tool
business_tools.append(organization_semantic_tool(_graph_workspace))
except Exception:
logger.exception("organization graph tool was not attached")
# Step 3: Load knowledge space resource if specified in ext_info
knowledge_resources: List[Any] = []
knowledge_context = ""
if knowledge_space:
try:
from kamidso.engine.serve.agent.resource.knowledge import (
KnowledgeSpaceRetrieverResource,
)
knowledge_resource = await KnowledgeSpaceRetrieverResource.create(
name=f"knowledge_space_{knowledge_space}",
space_name=knowledge_space,
top_k=4,
system_app=CFG.SYSTEM_APP,
)
knowledge_resources.append(knowledge_resource)
codegraph_tools_desc = (
"""
- kb_codegraph_explore: Query code structure (classes, call chains, inheritance)
- kb_codegraph_call_chain: Trace who calls / is called by a function
- kb_codegraph_class_hierarchy: Trace class inheritance and implementations
"""
if code_graph_available
else ""
)
knowledge_context = f"""
## Knowledge Base
- Knowledge space: {knowledge_resource.retriever_name or knowledge_space}
- Description: {knowledge_resource.retriever_desc or "Knowledge retrieval available"}
- Available tools:
- kb_ls: List files and directories in the knowledge base
- kb_glob: Search files by name or glob pattern
- kb_grep: Search file contents by keyword (prefer for exact matches)
- kb_cat: Read the content of a specific file
- semantic_search: Semantic search (use when kb_grep returns insufficient results){codegraph_tools_desc}
"""
logger.info(
f"Loaded knowledge space resource: {knowledge_space} "
f"(name: {knowledge_resource.retriever_name})"
)
except Exception as e:
logger.warning(f"Failed to load knowledge space resource: {e}", exc_info=e)
knowledge_context = f"""
## Knowledge Base
- Warning: Failed to load knowledge space '{knowledge_space}'. Error: {str(e)}
"""
# Step 4: Load database connectors attached to this turn
database_connector = None
database_connectors: Dict[str, Any] = {}
database_connector_labels: Dict[str, str] = {}
database_context = ""
database_sketch_entries: List[Tuple[str, List[str]]] = []
db_failure_state: Dict[str, Any] = {}
if attached_dbs:
sections: List[str] = []
try:
local_db_manager = ConnectorManager.get_instance(CFG.SYSTEM_APP)
except Exception as e:
local_db_manager = None
logger.warning(f"Failed to load connector manager: {e}", exc_info=e)
from kamidso.database_runtime.errors import note_database_connection_failure
note_database_connection_failure(
db_failure_state,
f"{type(e).__name__}: {e}",
language=_response_language(dialogue.ext_info),
)
database_context = f"""
## Database
- Warning: failed to load database connections. Error: {str(e)}
"""
if local_db_manager is not None:
def _load_one(ds_id: str, display_name: str):
connector = local_db_manager.get_connector(ds_id)
table_names = [str(name) for name in connector.get_table_names()]
table_info = connector.get_table_info_no_throw()
return connector, table_names, table_info
for ds_id, display_name in attached_dbs:
try:
connector, table_names, table_info = await run_in_threadpool(
_load_one, ds_id, display_name
)
db_type = str(getattr(connector, "db_type", "") or "")
dialect = str(
getattr(connector, "dialect", "")
or getattr(connector, "db_dialect", "")
or db_type
)
query_hint = _database_query_hint(db_type, dialect)
shown = table_names[:40]
hidden = len(table_names) - len(shown)
objects = ", ".join(shown) if shown else "(none)"
if hidden > 0:
objects = f"{objects} (+{hidden} more)"
database_sketch_entries.append((display_name, shown))
sections.append(
f"""### {display_name}
- Id: {ds_id}
- Type: {db_type or dialect or "sql"}
- Objects: {objects}
- Schema:
{table_info}
- {query_hint}
"""
)
database_connectors[ds_id] = connector
database_connector_labels[ds_id] = display_name
logger.info(
f"Loaded database connector: {ds_id} "
f"(tables: {', '.join(shown)})"
)
except Exception as e:
logger.warning(
f"Failed to load database connector {ds_id}: {e}", exc_info=e
)
from kamidso.database_runtime.errors import (
note_database_connection_failure,
)
note_database_connection_failure(
db_failure_state,
f"{type(e).__name__}: {e}",
language=_response_language(dialogue.ext_info),
)
sections.append(
f"""### {display_name}
- Warning: failed to load '{ds_id}'. Error: {str(e)}
"""
)
if database_connectors:
database_connector = next(iter(database_connectors.values()))
heading = "## Databases" if len(sections) > 1 else "## Database"
extra = (
"\nWhen more than one database is listed, pass `source=` "
"(connection name or id) to `database()`.\n"
if len(sections) > 1
else ""
)
database_context = heading + extra + "\n" + "\n".join(sections)
react_state: Dict[str, Any] = {
"skills_loaded": True, # Skills are pre-loaded now
"matched": None,
"skill_prompt": None,
"file_path": file_path,
"database_connector": database_connector,
"database_connectors": database_connectors,
"database_connector_labels": database_connector_labels,
"db_connection_failures": int(db_failure_state.get("db_connection_failures") or 0),
}
if native_connector_instances:
from kamidso.native_connectors.resolve import install_native_connectors
install_native_connectors(react_state, native_connector_instances)
if db_failure_state.get("db_connection_give_up"):
react_state["db_connection_give_up"] = db_failure_state["db_connection_give_up"]
from kamidso.assistant.data_sketch import compose_turn_data_sketch, paths_from_ext_info
from kamidso.engine.app.openapi.api_v1.tools.skill_runtime import (
SKILL_ORCHESTRATION_NORMS,
ensure_skill_execution,
format_manifest_context,
invalidate_manifest_cache,
optional_skill_hint_section,
)
from kamidso.engine.core.reasoning_policy import snapshot as reasoning_snapshot
invalidate_manifest_cache()
ensure_skill_execution(react_state)
react_state.update(reasoning_snapshot())
if organization_id:
react_state["organization_id"] = organization_id
# Pre-select skill if skill_name provided in ext_info
pre_matched_skill = None
explicit_skill_error = ""
if skill_name:
if use_native_dbgpt_skills() and enable_skill_extensions():
try:
pre_matched_skill = skill_service.load(
str(skill_name),
organization_id=organization_id,
react_state=react_state,
)
logger.info("Explicit skill loaded: %s", skill_name)
except SkillLoadError as exc:
explicit_skill_error = exc.message
logger.info("Explicit skill rejected: %s", exc.message)
else:
try:
pre_matched_skill = skill_service.get_installed(
str(skill_name), organization_id
)
except Exception:
pre_matched_skill = None
if pre_matched_skill:
logger.info("Optional skill hint from ext_info: %s", skill_name)
# Lightweight manifests stay in context for the whole ReAct turn.
# DB-GPT progressive disclosure: name + description only; SKILL.md via load_skill.
skills_context = format_manifest_context(organization_id=organization_id)
file_sketch_paths = paths_from_ext_info(
dialogue.ext_info if isinstance(dialogue.ext_info, dict) else None
)
if file_path and str(file_path).strip() and str(file_path) not in file_sketch_paths:
file_sketch_paths.append(str(file_path))
data_sketch = compose_turn_data_sketch(
file_paths=file_sketch_paths,
databases=database_sketch_entries,
)
def _pre_matched_skill_id(matched: Any) -> Optional[str]:
if matched is None:
return None
metadata = getattr(matched, "metadata", None)
if metadata is not None:
return getattr(metadata, "name", None)
return getattr(matched, "skill_id", None)
pre_matched_id = _pre_matched_skill_id(pre_matched_skill)
if explicit_skill_error:
picker_hint = f"## Selected skill\n{explicit_skill_error}"
elif (
pre_matched_id
and use_native_dbgpt_skills()
and enable_skill_extensions()
):
picker_hint = (
"## Selected skill\n"
f"The user selected `{pre_matched_id}`. Follow its loaded instructions. "
"Prerequisites and minimum reasoning for this skill still apply."
)
else:
picker_hint = optional_skill_hint_section(pre_matched_id)
skills_context = "\n\n".join(
part for part in (skills_context, data_sketch, picker_hint) if part
)
@tool(description="Load uploaded file info if provided.")
def load_file() -> str:
if not react_state.get("file_path"):
return json.dumps(
{"chunks": [{"output_type": "text", "content": "No file uploaded"}]},
ensure_ascii=False,
)
return json.dumps(
{
"chunks": [
{"output_type": "text", "content": react_state["file_path"]},
{
"output_type": "text",
"content": "File path provided by user upload",
},
]
},
ensure_ascii=False,
)
def _is_excel_skill(meta) -> bool:
name = (getattr(meta, "name", "") or "").lower()
desc = (getattr(meta, "description", "") or "").lower()
tags = [tag.lower() for tag in (getattr(meta, "tags", None) or [])]
return any(
token in name or token in desc or token in tags
for token in ["excel", "xlsx", "xls", "spreadsheet"]
)
@tool(description="Execute quick analysis on uploaded Excel/CSV file.")
async def execute_analysis() -> str:
from kamidso.engine.core.util.code.server import get_code_server
matched = react_state.get("matched")
if not react_state.get("file_path"):
return json.dumps(
{"chunks": [{"output_type": "text", "content": "No file to analyze"}]},
ensure_ascii=False,
)
if matched and not _is_excel_skill(matched.metadata):
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "Selected skill is not for Excel analysis",
}
]
},
ensure_ascii=False,
)
code_server = await get_code_server(CFG.SYSTEM_APP)
analysis_code = """
import json
import pandas as pd
file_path = r"{file_path}"
if file_path.lower().endswith((".xls", ".xlsx")):
df = pd.read_excel(file_path)
else:
df = pd.read_csv(file_path)
summary = {{
"shape": list(df.shape),
"columns": list(df.columns),
"dtypes": {{col: str(dtype) for col, dtype in df.dtypes.items()}},
"head": df.head(5).to_dict(orient="records"),
}}
print(json.dumps(summary, ensure_ascii=False))
""".format(file_path=react_state["file_path"])
result = await code_server.exec(analysis_code, "python")
output_text = (
result.output.decode("utf-8") if isinstance(result.output, bytes) else ""
)
chunks: List[Dict[str, Any]] = [
{"output_type": "code", "content": analysis_code.strip()}
]
if output_text:
try:
summary = json.loads(output_text)
chunks.append({"output_type": "json", "content": summary})
head_rows = summary.get("head")
columns = summary.get("columns")
if isinstance(head_rows, list) and isinstance(columns, list):
chunks.append(
{
"output_type": "table",
"content": {
"columns": [
{"title": col, "dataIndex": col, "key": col}
for col in columns
],
"rows": head_rows,
},
}
)
numeric_columns = [
col
for col, dtype in (summary.get("dtypes") or {}).items()
if "int" in dtype or "float" in dtype
]
if numeric_columns and isinstance(head_rows, list):
series_col = numeric_columns[0]
data = [
{"x": idx + 1, "y": row.get(series_col)}
for idx, row in enumerate(head_rows)
if row.get(series_col) is not None
]
if data:
chunks.append(
{
"output_type": "chart",
"content": {
"data": data,
"xField": "x",
"yField": "y",
},
}
)
except Exception:
chunks.append({"output_type": "text", "content": output_text})
return json.dumps({"chunks": chunks}, ensure_ascii=False)
@tool(description="Resolve required tools for the selected skill.")
def load_tools() -> str:
from kamidso.engine.core.agent.resource.base import AgentResource, ResourceType
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="Execute a tool by name with JSON args.")
async def execute_tool(tool_name: str, args: dict) -> str:
from kamidso.engine.core.agent.resource.base import AgentResource, ResourceType
try:
from kamidso.engine.core.agent.resource.connector.confirmation import (
_PENDING_CONFIRMATIONS,
)
from kamidso.engine.core.agent.resource.connector.manager import (
ConnectorManager as _ConnectorManager,
)
_cm = CFG.SYSTEM_APP.get_component(
"connector_manager", _ConnectorManager, default_component=None
)
if _cm is not None:
_interceptor = _cm.get_confirmation_interceptor()
_registry = _cm.get_confirmation_registry()
if _interceptor.should_confirm(tool_name, args):
import asyncio as _asyncio
import uuid as _uuid
_confirm_id = str(_uuid.uuid4())
_registry.register(_confirm_id)
_PENDING_CONFIRMATIONS[_confirm_id] = {
"confirm_id": _confirm_id,
"tool_name": tool_name,
"args_summary": _interceptor._summarize_args(args),
"message": f"即将执行写操作 {tool_name},是否确认?",
"timeout": 300,
}
try:
_approved = await _asyncio.wait_for(
_registry.wait_for(_confirm_id), timeout=300
)
except _asyncio.TimeoutError:
_approved = False
finally:
_PENDING_CONFIRMATIONS.pop(_confirm_id, None)
if not _approved:
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": "用户拒绝了此操作,工具执行已取消。",
}
]
},
ensure_ascii=False,
)
except Exception:
pass
rm = get_resource_manager(CFG.SYSTEM_APP)
try:
# Primary path: lookup via ResourceManager (lazy-registered business tools)
tool_resource = rm.build_resource_by_type(
ResourceType.Tool.value,
AgentResource(type=ResourceType.Tool.value, value=tool_name),
)
tool_pack = ToolPack([tool_resource])
result = await tool_pack.async_execute(resource_name=tool_name, **args)
return json.dumps(
{"chunks": [{"output_type": "text", "content": str(result)}]},
ensure_ascii=False,
)
except Exception as primary_exc:
# Fallback: if ResourceManager doesn't have the tool, try
# ConnectorManager active packs. This handles the case where LLM
# mistakenly routes an MCP connector tool through execute_tool
# instead of calling it directly.
try:
from kamidso.engine.core.agent.resource.connector.manager import (
ConnectorManager as _ConnectorManager,
)
_cm = CFG.SYSTEM_APP.get_component(
"connector_manager",
_ConnectorManager,
default_component=None,
)
if _cm is not None:
for _cid, _pack in _cm._active_packs.items():
if tool_name in _pack._resources:
result = await _pack.async_execute(
resource_name=tool_name, **args
)
logger.info(
"execute_tool dispatched '%s' via "
"ConnectorManager fallback (connector=%s). "
"Prefer direct Action call for connector "
"tools.",
tool_name,
_cid,
)
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": str(result),
}
]
},
ensure_ascii=False,
)
except Exception as fallback_exc:
logger.warning(
"execute_tool fallback to ConnectorManager failed for '%s': %s",
tool_name,
fallback_exc,
)
# When fallback found the tool but execution failed, surface
# that error to the LLM (more actionable than the
# ResourceManager primary error)
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": (
f"Tool execute failed: {fallback_exc} "
f"(primary lookup error: {primary_exc})"
),
}
]
},
ensure_ascii=False,
)
# Both lookups returned None — primary path's tool-not-found error wins
return json.dumps(
{
"chunks": [
{
"output_type": "text",
"content": f"Tool execute failed: {primary_exc}",
}
]
},
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,
)
# ── Import built-in tools from tools/ directory ──
from kamidso.engine.app.openapi.api_v1.tools import (
make_code_interpreter,
make_execute_analysis,
make_execute_skill_script_file,
make_execute_tool,
make_html_interpreter,
make_kb_tools,
make_knowledge_retrieve,
make_load_file,
make_load_skill,
make_load_tools,
make_question,
make_read_file,
make_shell_interpreter,
make_sql_query,
make_todowrite,
)
# ── Build tool instances via factory functions ──────────────────────────
# (Inline @tool definitions have been moved to tools/ directory.)
# Local helper aliases used by the SSE loop are defined below.
# ── Stream queue (created early so question tool can use it) ────────
stream_queue: asyncio.Queue = asyncio.Queue()
async def stream_callback(event_type: str, payload: Dict[str, Any]) -> None:
await stream_queue.put({"type": event_type, **payload})
# ── Build tool instances from tools/ directory ───────────────────────
_todo_list: List[Dict[str, str]] = []
# Reuse one visible plan card across all todowrite updates in this round.
# The mutable single-item list acts as a closure cell.
_todo_step_holder: List[str] = []
load_skill_tool = make_load_skill(react_state)
load_file_tool = make_load_file(react_state)
execute_analysis_tool = make_execute_analysis(react_state)
load_tools_tool = make_load_tools(react_state)
execute_tool_tool = make_execute_tool(react_state)
# Knowledge tools: use kb_tools (kb_ls, kb_glob, kb_grep, kb_cat, semantic_search)
# when a knowledge space is connected, otherwise fall back to knowledge_retrieve
if knowledge_space:
kb_tool_list = make_kb_tools(knowledge_space)
# Filter out codegraph tools when the space has no built code graph,
# so the agent never sees tools it cannot use successfully.
# @tool decorator wraps the function; the tool name lives on `._tool.name`
# (and `.__name__` via functools.wraps).
if not code_graph_available:
kb_tool_list = [
t
for t in kb_tool_list
if not getattr(getattr(t, "_tool", t), "name", "").startswith(
"kb_codegraph"
)
]
else:
# No knowledge space connected — use legacy knowledge_retrieve (no-op without resources)
kb_tool_list = [make_knowledge_retrieve(react_state, knowledge_resources)]
sql_query_tool = make_sql_query(
react_state,
database_connector,
connectors=database_connectors,
connector_labels=database_connector_labels,
)
code_interpreter_tool = make_code_interpreter(
react_state,
sql_enabled=_this_turn_allows_sql_query(attached_dbs),
database_runtime=_this_turn_uses_db_runtime(attached_dbs),
native_connector=bool(native_connector_instances),
)
connector_call_tool = None
if native_connector_instances and tool_mode != "knowledge":
from kamidso.engine.app.openapi.api_v1.tools.connector_call import (
make_connector_call,
)
connector_call_tool = make_connector_call(
react_state,
user_name=dialogue.user_name,
)
shell_interpreter_tool = make_shell_interpreter(react_state)
html_interpreter_tool = make_html_interpreter(react_state, DEFAULT_SKILLS_DIR)
todowrite_tool = make_todowrite(_todo_list, stream_callback)
question_tool = make_question(react_state, stream_callback)
# read_file lets the agent read back persisted tool results / snapshots
# from disk when a <persisted-output> block references a file path.
read_file_tool = make_read_file(react_state)
# Keep local aliases for backward compatibility (SSE loop references these names)
execute_skill_script_file_tool = make_execute_skill_script_file(react_state)
_todo_action_history: Dict[int, List[str]] = {}
def _active_todo_index() -> Optional[int]:
for idx, item in enumerate(_todo_list):
if item.get("status") == "in_progress":
return idx
return None
def _normalize_text(value: Optional[str]) -> str:
return (value or "").strip().lower()
def _is_report_like(text: str) -> bool:
keywords = [
"report",
"html",
"dashboard",
"visual",
"visualization",
"图表",
"报告",
"报表",
"可视化",
"渲染",
"展示",
]
return any(keyword in text for keyword in keywords)
def should_advance_todo(
action_name: Optional[str],
thought: Optional[str] = None,
observation_text: Optional[str] = None,
) -> bool:
"""Heuristically decide whether the current todo is actually complete."""
if not _todo_list:
return False
active_idx = _active_todo_index()
if active_idx is None:
return False
action_lower = _normalize_text(action_name)
thought_lower = _normalize_text(thought)
observation_lower = _normalize_text(observation_text)
current_todo = _normalize_text(_todo_list[active_idx].get("content"))
next_todo = (
_normalize_text(_todo_list[active_idx + 1].get("content"))
if active_idx + 1 < len(_todo_list)
else ""
)
history = _todo_action_history.setdefault(active_idx, [])
if action_lower:
history.append(action_lower)
transition_markers = [
"next step",
"now i need",
"now i should",
"now let me",
"现在需要",
"下一步",
"接下来",
"然后",
"接着",
"接下来我将",
]
if next_todo and any(marker in thought_lower for marker in transition_markers):
if any(token and token in thought_lower for token in next_todo.split()):
return True
if action_lower == "html_interpreter":
return True
if action_lower in {
"load_skill",
"execute_skill_script",
"execute_skill_script_file",
}:
if next_todo and next_todo in thought_lower:
return True
if _is_report_like(next_todo) and _is_report_like(thought_lower):
return True
if action_lower == "sql_query":
sql_calls = sum(1 for item in history if item == "sql_query")
if sql_calls < 3:
return False
if next_todo and any(
token and token in thought_lower for token in next_todo.split()
):
return True
if _is_report_like(next_todo) and (
"summary" in thought_lower
or "summarize" in thought_lower
or "整理" in thought_lower
or "汇总" in thought_lower
or "报告" in thought_lower
):
return True
if current_todo and not _is_report_like(current_todo):
completion_markers = [
"enough information",
"collected enough",
"gathered enough",
"completed metadata",
"obtained the overview",
"获取了足够",
"已经获取了足够",
"已完成",
"已获取",
"整理一下",
]
if any(marker in thought_lower for marker in completion_markers):
return True
return False
if action_lower in {"code_interpreter", "execute_tool", "shell_interpreter"}:
if _is_report_like(current_todo):
return False
if next_todo and any(
token and token in thought_lower for token in next_todo.split()
):
return True
if observation_lower and _is_report_like(observation_lower):
return True
return False
def advance_todo_list() -> Optional[List[Dict[str, str]]]:
"""Advance one todo when the current task appears substantively complete."""
if not _todo_list:
return None
changed = False
active_idx = _active_todo_index()
if active_idx is not None:
_todo_list[active_idx]["status"] = "completed"
changed = True
_todo_action_history.pop(active_idx, None)
for next_item in _todo_list[active_idx + 1 :]:
if next_item.get("status") == "pending":
next_item["status"] = "in_progress"
_todo_action_history.pop(active_idx + 1, None)
break
else:
for item in _todo_list:
if item.get("status") == "pending":
item["status"] = "in_progress"
changed = True
break
return list(_todo_list) if changed else None
llm_client = DefaultLLMClient(
CFG.SYSTEM_APP.get_component(
ComponentType.WORKER_MANAGER_FACTORY, WorkerManagerFactory
).create(),
auto_convert_message=True,
)
if dialogue.model_name:
llm_config = LLMConfig(
llm_client=llm_client,
llm_strategy=LLMStrategyType.Priority,
strategy_context=json.dumps([dialogue.model_name]),
)
else:
llm_config = LLMConfig(llm_client=llm_client)
conv_id = dialogue.conv_uid or str(uuid.uuid4())
react_state["conv_id"] = conv_id
if isinstance(dialogue.ext_info, dict):
if dialogue.ext_info.get("workspace_id"):
react_state["workspace_id"] = str(dialogue.ext_info.get("workspace_id"))
if dialogue.ext_info.get("user_id"):
react_state["user_id"] = str(dialogue.ext_info.get("user_id"))
if dialogue.ext_info.get("organization_id"):
react_state["organization_id"] = str(dialogue.ext_info.get("organization_id"))
# HTTP handlers set dialogue.user_name from the auth token before streaming;
# the product engine path puts user/workspace on ext_info. There is no
# user_token in this function scope.
if dialogue.user_name and not react_state.get("user_id"):
react_state["user_id"] = str(dialogue.user_name)
if attachment_ctx is not None:
# Public manifests for runtime tools plus the internal primary
# materialized path / files_json mapping (execution-only values —
# never written to prompts, logs or history).
react_state.update(react_state_patch(attachment_ctx))
elif isinstance(dialogue.ext_info, dict):
resolved_paths = dialogue.ext_info.get("kamidso_resolved_file_paths")
if isinstance(resolved_paths, list) and resolved_paths:
from kamidso.engine.app.openapi.api_v1.tools.execute_analysis import (
_sanitize_conv_id,
)
from kamidso.engine.app.openapi.api_v1.tools.file_access import (
apply_resolved_file_paths,
)
from kamidso.engine.core.configs.model_config import PILOT_PATH
safe_id = _sanitize_conv_id(conv_id) or "default"
apply_resolved_file_paths(
react_state,
resolved_paths,
dest_dir=os.path.join(PILOT_PATH, "tmp", safe_id),
)
# Public per-turn snapshot of input files for the persisted history
# payload (v2). Only public metadata — never server paths, storage URIs,
# owner ids, hashes or inspection bodies. Later turns resolve files
# fresh from the registry; old payloads are never scanned for files.
input_files_snapshot = build_input_files_v2(
attachment_ctx.manifests if attachment_ctx is not None else ()
)
agent_memory = build_agent_memory(
conv_id,
REACT_AGENT_MEMORY_CACHE,
system_app=CFG.SYSTEM_APP,
organization_id=react_state.get("organization_id"),
)
conv_serve = ConversationServe.get_instance(CFG.SYSTEM_APP)
storage_conv = StorageConversation(
conv_uid=conv_id,
chat_mode=dialogue.chat_mode or "chat_react_agent",
user_name=dialogue.user_name,
sys_code=dialogue.sys_code,
summary=dialogue.user_input,
app_code=dialogue.app_code,
conv_storage=conv_serve.conv_storage,
message_storage=conv_serve.message_storage,
)
storage_conv.save_to_storage()
storage_conv.start_new_round()
# Prefer durable Kamidso Mongo history when provided (survives backend
# restart). Engine StorageConversation is in-memory SQLite and is empty
# after process restart even when the UI still shows prior turns.
historical_dialogues: List[AgentMessage] = []
_ext_for_history = dialogue.ext_info if isinstance(dialogue.ext_info, dict) else {}
_kamidso_history = _ext_for_history.get("kamidso_history")
_kamidso_last_report = _ext_for_history.get("kamidso_last_report")
if isinstance(_kamidso_history, list) and _kamidso_history:
historical_dialogues = _dialogues_from_kamidso_history(
_kamidso_history, last_report=_kamidso_last_report
)
if not historical_dialogues:
# Fallback: same-process engine chat_history (lost on restart).
for _msg in storage_conv.get_history_message():
if _msg.type == "human":
historical_dialogues.append(AgentMessage(content=_msg.content))
elif _msg.type == "ai":
# Final answers are persisted as view messages; skip ReAct scratchpad rows.
_ai_content = _msg.content or ""
if _ai_content and not _is_react_scratchpad_content(_ai_content):
historical_dialogues.append(AgentMessage(content=_ai_content))
elif _msg.type == "view":
# view 消息存的是 history_payload(JSON),提取 final_content 作为 AI 回答
_content = _msg.content
try:
_payload = (
json.loads(_content) if isinstance(_content, str) else _content
)
if isinstance(_payload, dict):
_content = _payload.get("final_content") or ""
if _kamidso_last_report is None:
for _step in _payload.get("steps") or []:
if not isinstance(_step, dict):
continue
if _step.get("action") == "html_interpreter":
_obs = _step.get("observation") or ""
try:
_obs_payload = (
json.loads(_obs)
if isinstance(_obs, str)
else _obs
)
except Exception:
_obs_payload = None
if isinstance(_obs_payload, dict):
for _chunk in _obs_payload.get("chunks") or []:
if (
isinstance(_chunk, dict)
and _chunk.get("output_type") == "html"
):
_kamidso_last_report = {
"type": "html",
"content": _chunk.get("content"),
}
break
except Exception:
pass
if _content:
historical_dialogues.append(AgentMessage(content=_content))
if _kamidso_last_report and historical_dialogues:
_report_text = _format_report_for_history(_kamidso_last_report)
if _report_text:
_target = (
historical_dialogues[-1]
if len(historical_dialogues) % 2 == 0
else (
historical_dialogues[-2]
if len(historical_dialogues) >= 2
else None
)
)
if _target is not None and "[Previous structured report]" not in (
_target.content or ""
):
_target.content = (
f"{_target.content}\n\n"
f"[Previous structured report]\n{_report_text}"
)
storage_conv.add_user_message(user_input)
try:
requested_tokens = int(dialogue.max_new_tokens or 0)
except (TypeError, ValueError):
requested_tokens = 0
context = AgentContext(
conv_id=conv_id,
gpts_app_code="react_agent",
gpts_app_name="ReAct",
language=_resolve_agent_language(dialogue.ext_info),
temperature=dialogue.temperature if dialogue.temperature is not None else 0.2,
max_new_tokens=max(requested_tokens, 65536),
max_context_tokens=256000,
enable_context_management=True,
enable_native_function_calling=True,
output_dir=op_snapshot_dir(),
)
# file_ids requests use the public manifest block; legacy file_path and
# pure-text requests keep their existing wording byte-for-byte.
# Kamidso-staged paths replace the singular legacy block so a second pin
# is not hidden behind "the attached file".
file_context = _compose_react_file_context(
attachment_ctx,
react_state.get("file_path") or file_path,
dialogue.ext_info if isinstance(dialogue.ext_info, dict) else None,
)
_ext_info = dialogue.ext_info if isinstance(dialogue.ext_info, dict) else {}
_skill_source = str(_ext_info.get("skill_source") or "user").strip() or "user"
skill_prompt_context = ""
execution_instruction = ""
kamidso_behavior = _kamidso_behavior_section(dialogue.ext_info)
# Build a hint listing all images currently available in
# STATIC_MESSAGE_IMG_PATH so the LLM can reference them correctly in
# html_interpreter.
# NOTE: This is the initial hint at prompt build time. Images generated
# during the session are tracked in react_state["generated_images"] and
# appended to html_interpreter output dynamically.
available_images_hint = ""
# Picker highlight is never exclusive skill mode. Full catalog + tools stay
# available; the agent calls load_skill only when a description fits.
is_skill_mode = False
_skill_name = pre_matched_id or "skill"
if (
pre_matched_skill is not None
and use_native_dbgpt_skills()
and enable_skill_extensions()
and getattr(pre_matched_skill, "instructions", None)
):
skill_prompt_context, execution_instruction = _skill_prompt_sections(
_skill_name,
pre_matched_skill.instructions,
_skill_source,
)
file_count = _attached_file_count(
attachment_ctx=attachment_ctx,
ext_info=dialogue.ext_info if isinstance(dialogue.ext_info, dict) else None,
file_path=react_state.get("file_path") or file_path,
)
skill_mode_intro = _skill_mode_intro(_skill_source, file_count)
has_attached_files = _has_attached_files(
attachment_ctx=attachment_ctx,
ext_info=dialogue.ext_info if isinstance(dialogue.ext_info, dict) else None,
file_path=react_state.get("file_path") or file_path,
)
db_runtime = _this_turn_uses_db_runtime(attached_dbs)
source_priority = _this_turn_source_section(
database_labels=[label for _id, label in attached_dbs],
database_name=database_name,
database_label=str(
(dialogue.ext_info or {}).get("database_label") or ""
)
if isinstance(dialogue.ext_info, dict)
else "",
has_files=has_attached_files,
db_runtime=db_runtime,
)
suggested_skill_block = ""
skill_file_tools_hint = ""
if has_attached_files:
skill_file_tools_hint = """
- **Attached data file** -> Read the data sketch. Call `load_skill` only if a
catalog description matches, then `execute_skill_script_file` with `input_file`.
Otherwise `load_file` then `code_interpreter` (pandas). Never ask the user to
paste file content. Do not call `sql_query` unless a database is also attached.
"""
sql_enabled = _this_turn_allows_sql_query(attached_dbs)
expose_sql_query = _this_turn_exposes_sql_query(attached_dbs)
heavy_delivery_rule = _heavy_delivery_rule(
sql_enabled=sql_enabled,
has_files=has_attached_files,
db_runtime=db_runtime,
)
report_mode = _report_mode_enabled(
dialogue.ext_info if isinstance(dialogue.ext_info, dict) else None
)
report_mode_rule = _report_mode_rule(report_mode)
todowrite_policy = _todowrite_policy(report_mode)
report_deliverable_rule = REPORT_DELIVERABLE_RULE + "\n" if report_mode else ""
report_html_contract = (
"## HTML report tool\n" + HTML_INTERPRETER_TOOL_DESC + "\n"
if report_mode
else ""
)
skill_report_norm = (
"- **Need to generate report** -> Call `html_interpreter`. **Default: "
"directly pass complete HTML via the `html` parameter** — generate the "
"full HTML yourself (DOCTYPE, html, head, body, styles, content). Visible "
"text MUST be Persian with dir=\"rtl\" lang=\"fa\". Only use "
"`template_path` if the skill explicitly provides HTML templates. Do not "
"use `code_interpreter` to generate the report.\n"
if report_mode
else ""
)
skill_report_tool_desc = (
f"5. {HTML_INTERPRETER_TOOL_DESC}\n"
f" {available_images_hint}\n"
if report_mode
else ""
)
skill_get_resource_note = (
" - Note: For generating reports, prefer html_interpreter with the html "
"parameter. Only use template_path if the skill provides templates.\n"
if report_mode
else " - Note: Report mode is OFF — do not call html_interpreter.\n"
)
skill_terminate_rule = (
"5. First `todowrite` a coverage plan, then after `html_interpreter`, "
"call terminate with a FULL independent analysis covering every asked "
"section. The Action Input format must be "
'{"result": "complete analysis in Persian"}.'
if report_mode
else (
"5. When the answer is ready, call terminate to return the final "
"result. The Action Input format must be "
'{"result": "final answer"}.'
)
)
full_report_flow = (
"When report mode is ON: todowrite (coverage plan) -> inspect schema "
"and real field names -> load_skill if a catalog skill matches ->\n"
"code_interpreter with database().query / database().aggregate "
"($group/$lookup or SQL GROUP BY/JOIN on the server) ->\n"
"html_interpreter (complete Persian RTL HTML) -> terminate (FULL "
"independent analysis; never dump Thought/Action text).\n"
"Do not dump full query tables into Python to group them.\n"
"Do not print HTML via code_interpreter then terminate without "
"html_interpreter.\n"
if report_mode
else ""
)
full_report_scenario = (
"- For report generation: same playbook; finally call\n"
"`html_interpreter` to render the Persian RTL HTML report, then "
"terminate with a full independent analysis.\n"
if report_mode
else (
"- Report mode is OFF: do not call `html_interpreter`. Answer with "
"`terminate`.\n"
)
)
full_report_tool_desc = (
f"17. {HTML_INTERPRETER_TOOL_DESC}\n"
if report_mode
else ""
)
skill_sql_query_desc = _sql_query_skill_catalog(
sql_enabled=sql_enabled, db_runtime=db_runtime
)
full_sql_query_desc = _sql_query_full_catalog(
sql_enabled=sql_enabled, db_runtime=db_runtime
)
# Inject connector tools — only the ones the user explicitly selected.
connector_tool_extras: List[Any] = []
_connector_manager = None
try:
from kamidso.engine.core.agent.resource.connector.manager import (
ConnectorManager as _ConnectorManager,
)
_connector_manager = CFG.SYSTEM_APP.get_component(
"connector_manager", _ConnectorManager, default_component=None
)
if _connector_manager is not None and mcp_connector_ids:
connector_tool_extras, _missing = _select_connector_tools(
mcp_connector_ids, _connector_manager
)
for _mid in _missing:
logger.warning(
"_react_agent_stream: connector_id %s not active, skipping",
_mid,
)
if connector_tool_extras:
logger.info(
"_react_agent_stream: injected %d connector tool pack(s) "
"(selected: %d)",
len(connector_tool_extras),
len(mcp_connector_ids),
)
except Exception:
pass # graceful degradation — connector tools are optional
# Parallel sub-agent dispatch is exposed only in the full-tool branch
# below. It shares the main stream queue so child lifecycle events and
# question events use the same ordered SSE channel.
_max_parallel_subagents = 3
try:
_app_cfg = CFG.SYSTEM_APP.config.configs.get("app_config")
_web_cfg = getattr(getattr(_app_cfg, "service", None), "web", None)
_agent_ctx = getattr(_web_cfg, "agent_context", None)
_cfg_val = getattr(_agent_ctx, "max_parallel_subagents", None)
if isinstance(_cfg_val, int) and _cfg_val > 0:
_max_parallel_subagents = _cfg_val
except Exception:
logger.debug("Failed to read max_parallel_subagents; using default 3")
async def _emit_subagent_event(payload: Dict[str, Any]) -> None:
await stream_queue.put(payload)
dispatch_parallel_tasks = make_dispatch_tool(
parent_conv_id=conv_id,
llm_client=llm_client,
sub_model_name=dialogue.model_name,
database_connector=database_connector,
knowledge_resources=knowledge_resources,
connector_tool_extras=connector_tool_extras,
connector_manager=_connector_manager,
emit_event=_emit_subagent_event,
max_parallel=_max_parallel_subagents,
report_mode=report_mode,
native_connectors=native_connector_instances,
user_name=dialogue.user_name,
)
_native_prompt = use_native_dbgpt_prompt()
native_section = ""
if native_connector_instances:
try:
from kamidso.native_connectors.prompt import render_native_prompt
native_section = render_native_prompt(native_connector_instances)
except Exception:
logger.exception("native connector prompt failed")
if is_skill_mode:
if attached_dbs and not has_attached_files:
skill_compute_rule = (
"4. **[Mandatory Rule] Analyze the attached database in `code_interpreter` with `database()`. "
f"{report_mode_rule} "
"Do not reopen Excel/CSV files from earlier turns.**"
)
elif has_attached_files and not attached_dbs:
skill_compute_rule = (
"4. **[Mandatory Rule] Analyze the attached file with `load_file` then "
"`code_interpreter` (pandas). Do NOT call `sql_query`. "
f"{report_mode_rule}**"
)
else:
skill_compute_rule = (
"4. **Report rule:**\n"
f"{report_mode_rule}\n"
"Compute data with `code_interpreter` first when needed. "
"Do NOT invent a new report theme."
)
# Simplified prompt for skill mode - only skill-related tools (+
# html_interpreter when report_mode is ON)
if not _native_prompt:
workflow_prompt = f"""
{_KAMIDSO_IDENTITY}
{_system_clock_line()}
{skill_mode_intro}
Please always response in the same language as the user's input language.
## Autonomous Decision Principles
1. Strictly follow the instructions of the loaded skill.
2. For each step, output Thought -> Action Intention -> Action Reason -> Action
-> Action Input.
3. Wait for the system to return Observation before deciding on the next step.
{skill_compute_rule}
{skill_terminate_rule}
{report_deliverable_rule}{report_html_contract}
{skill_prompt_context}
{execution_instruction}
{kamidso_behavior}
## Available Skills List
{skills_context}
{SKILL_ORCHESTRATION_NORMS}
## Skill Execution Norms
### Resource Usage
{skill_file_tools_hint}- **Need to execute skill script** -> Use `execute_skill_script_file` with
parameters {{"skill_name": "skill name", "script_file_name": "script file name",
"args": {{parameters}}}}. This tool will automatically handle image copying and
data recording.
- **Need to understand indicator definitions/analysis framework** -> Use
`get_skill_resource` and specify the `references/xxx.md` path to read the
reference document.
- **Encounter image file** -> If the model does not support image input, it will
return an error prompt.
{skill_report_norm}
## Available Tools Description
1. **load_skill**: Load full SKILL.md for one skill id from the manifest list.
Parameters: {{"skill_id": "sales-intelligence"}}
2. **execute_skill_script_file** (recommended for executing skill scripts): Execute
script files in the skills scripts directory, automatically handling
post-processing such as copying images to the static directory and recording
calculation results.
Parameters: {{"skill_name": "skill name", "script_file_name": "script file
name", "args": {{parameters}}}}
- Example: {{"skill_name": "{_skill_name}",
"script_file_name": "calculate_ratios.py",
"args": {{"input_data": "..."}}}}
- **Must use this tool when executing skill scripts**, do not use
shell_interpreter.
2. **get_skill_resource**: Read reference documents, configurations, templates, and
other non-script resource files in the skill.
Parameters: {{"skill_name": "skill name", "resource_path": "resource path"}}
- Read reference document: {{"skill_name": "{_skill_name}",
"resource_path": "references/analysis_framework.md"}}
{skill_get_resource_note}
3. **execute_skill_script**: Execute the inline script defined in the skill
(backup). Parameters: {{"skill_name": "skill name", "script_name": "script name",
"args": {{"parameter name": "parameter value"}}}}
4. **shell_interpreter**: Execute shell/bash commands (only for non-skill script
system commands, such as ls, cat, etc.).
Parameters: {{"code": "shell command"}}
- Each call is independent and does not retain state. If multi-step operations
are needed, use `&&` or `;` to connect commands.
- **Note: Do not use this tool to execute skill scripts**, as it will not
automatically handle images and data recording.
{skill_report_tool_desc}{skill_sql_query_desc}
7. **todowrite**: Create and manage a structured task list. Use for complex tasks
(3+ steps) to plan and track progress. Pass the FULL list every time. Each item:
{{"content": "description", "status": "pending|in_progress|completed|cancelled",
"priority": "high|medium|low"}}. Only ONE task in_progress at a time.
IMPORTANT: You MUST call todowrite again after EACH task completes to update status.
The user sees progress in real time — never skip an update.
Parameters: {{"todos": [{{...}}]}}
8. **question**: Ask the user a question and wait for their response. Use this tool
when you need user input, clarification, or a decision to proceed. The tool blocks
until the user answers.
Parameters: {{"questions": [{{"question": "...", "header": "...", "options": [
{{"label": "...", "description": "..."}}, ...]}}]}}. Set multiple=true to allow
multiple selections. The tool returns the user's selected answers.
9. **terminate**: Return the final answer when the task is completed. Action Input
must be {{"result": "your final answer content"}}.
## Task Management
{todowrite_policy}
CRITICAL: You MUST call `todowrite` to update the task list at EVERY transition:
1. BEFORE starting a task: mark it `in_progress` (call todowrite)
2. AFTER finishing a task: mark it `completed` AND mark the next one
`in_progress` (call todowrite)
3. Never skip updating — the user sees this progress in real time.
Example flow for 3 tasks:
- Create plan: [task1=in_progress, task2=pending, task3=pending] → call todowrite
- Finish task1: [task1=completed, task2=in_progress, task3=pending] → call todowrite
- Finish task2: [task1=completed, task2=completed, task3=in_progress] → call todowrite
- Finish task3: [task1=completed, task2=completed, task3=completed] → call todowrite
{file_context}
{source_priority}
{suggested_skill_block}
{knowledge_context}
{database_context}
## ReAct Output Format
Must output for each interaction round:
Thought: Analyze current task status and think about what to do next
{_REACT_BRIEF_LIMITS.strip()}
Action: The selected tool name (must be one of the tools listed above)
Action Input: The JSON format of tool parameters
IMPORTANT: Never emit native tool-call markup such as
<|tool_calls_section_begin|>, <|tool_call_begin|>, <|tool_call_argument_begin|>
or any other <|...|> tokens. Tool calls are ONLY valid in the textual
Thought/Action/Action Input format shown above.
Never use Chinese in any user-visible text.
""".strip()
if tool_mode == "knowledge":
# Knowledge-chat mode: only kb tools + todowrite + terminate.
# No skill/shell/sql/html/code tools to keep the agent focused.
tool_pack = ToolPack(
[load_skill_tool, todowrite_tool, question_tool, Terminate()]
+ business_tools
+ connector_tool_extras
)
else:
skill_mode_tools: List[Any] = [
load_skill_tool,
execute_skill_script,
get_skill_resource,
execute_skill_script_file_tool,
]
if connector_call_tool is not None:
skill_mode_tools.append(connector_call_tool)
skill_mode_tools.append(code_interpreter_tool)
if has_attached_files:
skill_mode_tools.append(load_file_tool)
skill_mode_tools.append(shell_interpreter_tool)
if report_mode:
skill_mode_tools.append(html_interpreter_tool)
if expose_sql_query:
skill_mode_tools.append(sql_query_tool)
skill_mode_tools.extend(
[
todowrite_tool,
question_tool,
Terminate(),
]
)
tool_pack = ToolPack(
skill_mode_tools + business_tools + connector_tool_extras
)
else:
# Full prompt with all tools when no skill is pre-selected
codegraph_section = (
"13.1. **kb_codegraph_explore**: Query the code knowledge graph for "
"structural info (classes, call chains, inheritance).\n"
'Parameters: {"query": "class/function name or \'who calls X\'"}\n'
"13.2. **kb_codegraph_call_chain**: Trace callers/callees of a function.\n"
'Parameters: {"function_name": "function name", "depth": 2, '
'"direction": "callers or callees"}\n'
"13.3. **kb_codegraph_class_hierarchy**: Trace class inheritance and "
"implementations.\n"
'Parameters: {"class_name": "class or interface name"}\n'
if code_graph_available
else ""
)
if not _native_prompt:
workflow_prompt = f"""
{_KAMIDSO_IDENTITY}
{_system_clock_line()}
You can autonomously select tools to solve problems based on user tasks.
Please always response in the same language as the user's input language.
## Autonomous Decision Principles
1. Carefully analyze the user's task requirements.
2. Autonomously select required tools based on requirements (do not follow a fixed
order, select as needed).
3. For each step, output Thought -> Action Intention -> Action Reason -> Action
-> Action Input.
4. Wait for the system to return Observation before deciding on the next step.
5. When the task is completed, call the terminate tool to return the final result.
The Action Input format must be {{"result": "final answer"}}.
6. **Report rule:** {report_mode_rule}
{full_report_flow}{report_deliverable_rule}{report_html_contract}{heavy_delivery_rule}
## Task Management
{todowrite_policy}
- Parallel exception: when the task contains 2 or more mutually independent
subtasks eligible for `dispatch_parallel_tasks`, use `todowrite` even when
the overall task has fewer than 3 steps.
- After splitting tasks with `todowrite`, delegate independent items with no
ordering dependency to `dispatch_parallel_tasks` (one sub-agent per item).
`todowrite` decomposes and tracks work; `dispatch_parallel_tasks` only executes
already-split items.
- The "exactly one in_progress" rule controls the visible todo state; it does
not prevent dispatching other independent pending items in the same batch.
CRITICAL: You MUST call `todowrite` to update the task list at EVERY transition:
1. BEFORE starting a task: mark it `in_progress` (call todowrite)
2. AFTER finishing a task: mark it `completed` AND mark the next one
`in_progress` (call todowrite)
3. Never skip updating — the user sees this progress in real time.
Example flow for 3 tasks:
- Create plan: [task1=in_progress, task2=pending, task3=pending] → call todowrite
- Finish task1: [task1=completed, task2=in_progress, task3=pending] → call todowrite
- Finish task2: [task1=completed, task2=completed, task3=in_progress] → call todowrite
- Finish task3: [task1=completed, task2=completed, task3=completed] → call todowrite
## Available Skills List
{skills_context}
{SKILL_ORCHESTRATION_NORMS}
## Skill Execution Norms (Important)
When using a skill, the following rules must be followed:
### 1. Understand the Workflow
After loading the skill, carefully read the **Core Workflow** section in SKILL.md
and execute it in order. If a step explicitly states conditions to skip (such as
when user intent is clear), directly skip to the next step; do not force the
execution of every step. Prioritize producing results quickly, and perform
iterative optimization in subsequent steps.
### 2. Resource Usage Timing
- **Need to calculate/process data** -> Use `execute_skill_script_file` to execute
scripts in the skill's scripts directory (this tool automatically handles images
and data recording). Parameters are {{"skill_name": "skill name",
"script_file_name": "script.py", "args": {{parameters}}}}.
- **Need to understand indicator definitions/analysis framework** -> Use
`get_skill_resource` and specify the `references/xxx.md` path to read the
reference document.
- **Encounter image file** -> If the model does not support image input, it will
return an error prompt.
### 3. Execution Order
Complete each workflow step before moving to the next. Do not mix multiple tool
calls in the same step.
### 4. Special Scenarios
{full_report_scenario}
## Available Tools Description
1. **load_skill**: Load full SKILL.md for one skill. Parameters:
{{"skill_id": "sales-intelligence"}} or {{"skill_name": "skill name"}}
2. **execute_skill_script_file**: Execute script files in the skill's scripts
directory. Parameters: {{"skill_name": "skill name",
"script_file_name": "script file name", "args": {{parameters}}}}
3. **get_skill_resource**: Read reference documents in the skill.
Parameters: {{"skill_name": "skill name", "resource_path": "resource path"}}
4. **execute_skill_script**: Execute the inline script defined in the skill.
Parameters: {{"skill_name": "skill name", "script_name": "script name",
"args": {{parameters}}}}
5. **shell_interpreter**: Execute shell/bash commands.
Parameters: {{"code": "shell command"}}
6. **code_interpreter**: Execute arbitrary Python code.
Parameters: {{"code": "python code string"}}
When the user asks for Excel/CSV output: after the final ranking query, you MUST
``pd.read_json(os.path.join(PLOT_DIR, "query_results", "latest.json"))`` then
``df.to_excel(os.path.join(PLOT_DIR, "best_customers.xlsx"), index=False)`` (or
to_csv). A chat table alone does NOT satisfy an Excel request. Then
``terminate``. Do not use code_interpreter mid-exploration just to print query
JSON. Do not paste rows into code or open op_snapshots.
7. **load_file**: Load uploaded file info. Parameters: none.
8. **execute_analysis**: Execute quick analysis on uploaded Excel/CSV file.
Parameters: none.
9. **kb_ls**: List files and directories in the knowledge base.
Parameters: {{"path": "directory path (optional)"}}
10. **kb_glob**: Search files by name or glob pattern in the knowledge base.
Parameters: {{"pattern": "file name keyword or glob pattern"}}
11. **kb_grep**: Search file contents by keyword in the knowledge base. Prefer for exact matches.
Parameters: {{"query": "search keyword", "path": "directory filter (optional)", "file_pattern": "file pattern like *.py (optional)"}}
12. **kb_cat**: Read the content of a specific file in the knowledge base.
Parameters: {{"path": "file path like src/main.py", "start_line": "start line (optional)", "end_line": "end line (optional, 0 = to end)"}}
13. **semantic_search**: Semantic search in the knowledge base. Use when kb_grep returns insufficient results.
Parameters: {{"query": "search query in natural language", "top_k": "number of results (optional)"}}
{codegraph_section}{full_sql_query_desc}
15. **load_tools**: Resolve required tools for the selected skill. Parameters: none.
16. **execute_tool**: Execute a tool by name with JSON args.
Parameters: {{"tool_name": "tool name", "args": {{parameters}}}}
{full_report_tool_desc}18. **todowrite**: Create and manage a structured task list. Use for complex tasks
(3+ steps) to plan and track progress. Pass the FULL list every time. Each item:
{{"content": "description", "status": "pending|in_progress|completed|cancelled",
"priority": "high|medium|low"}}. Only ONE task in_progress at a time.
IMPORTANT: You MUST call todowrite again after EACH task completes to update status.
The user sees progress in real time — never skip an update.
Parameters: {{"todos": [{{...}}]}}
15. **dispatch_parallel_tasks**: Execute 2 or more mutually independent todo
items concurrently with isolated sub-agents. Each task needs a self-contained
goal and may include shared context and a display title.
Parameters: {{"tasks": [{{"goal": "...", "context": "...", "title": "..."}}]}}
16. **question**: Ask the user a question and wait for their response. Use this tool
when you need user input, clarification, or a decision to proceed. The tool blocks
until the user answers.
Parameters: {{"questions": [{{"question": "...", "header": "...", "options": [
{{"label": "...", "description": "..."}}, ...]}}]}}. Set multiple=true to allow
multiple selections. The tool returns the user's selected answers.
17. **terminate**: Finish the task. Parameters: {{"result": "final answer"}}
{file_context}
{source_priority}
{suggested_skill_block}
{knowledge_context}
{database_context}
{kamidso_behavior}
## ReAct Output Format
Must output for each interaction round:
Thought: Analyze current task status and think about what to do next
{_REACT_BRIEF_LIMITS.strip()}
Action: The selected tool name
Action Input: The JSON format of tool parameters
IMPORTANT: Never emit native tool-call markup such as
<|tool_calls_section_begin|>, <|tool_call_begin|>, <|tool_call_argument_begin|>
or any other <|...|> tokens. Tool calls are ONLY valid in the textual
Thought/Action/Action Input format shown above.
Never use Chinese in any user-visible text.
""".strip()
workflow_prompt = workflow_prompt + "\n\n" + DISPATCH_PROMPT_SECTION
if tool_mode == "knowledge":
# Knowledge-chat mode (no pre-selected skill): only kb
# tools + todowrite + terminate. kb_tool_list already
# contains kb_semantic_search, kb_ls, kb_glob, kb_grep,
# kb_cat and optionally codegraph tools.
tool_pack = ToolPack(
kb_tool_list
+ [load_skill_tool, todowrite_tool, question_tool, Terminate()]
+ business_tools
+ connector_tool_extras
)
else:
analysis_tools: List[Any] = [
execute_skill_script,
get_skill_resource,
execute_skill_script_file_tool,
]
if connector_call_tool is not None:
analysis_tools.append(connector_call_tool)
analysis_tools.extend(
[
code_interpreter_tool,
load_file_tool,
execute_analysis_tool,
shell_interpreter_tool,
]
)
if report_mode:
analysis_tools.append(html_interpreter_tool)
if expose_sql_query:
analysis_tools.append(sql_query_tool)
analysis_tools.extend(
[
read_file_tool,
todowrite_tool,
execute_tool_tool,
dispatch_parallel_tasks,
question_tool,
Terminate(),
]
)
tool_pack = ToolPack(
[
load_skill_tool,
load_tools_tool,
]
+ kb_tool_list
+ analysis_tools
+ business_tools
+ connector_tool_extras
)
# Debug: print all registered tools
logger.info(f"ToolPack resources: {list(tool_pack._resources.keys())}")
if "execute_skill_script" not in tool_pack._resources:
logger.error("execute_skill_script NOT in ToolPack!")
# Combine tool_pack and knowledge_resources into a single ResourcePack
all_resources = [tool_pack]
if knowledge_resources:
all_resources.extend(knowledge_resources)
workflow_prompt_template = None
if not _native_prompt:
# --- Connector system prompt injection (T11) ---
try:
from kamidso.engine.core.agent.resource.connector.manager import (
ConnectorManager as _ConnectorManager,
)
_cm = CFG.SYSTEM_APP.get_component(
"connector_manager", _ConnectorManager, default_component=None
)
if _cm is not None and mcp_connector_ids:
_active = _cm.list_active()
# Only describe connectors the user explicitly selected.
# Iterate mcp ids so native connectors stay out of this block.
_active_map = {c["connector_id"]: c for c in _active if isinstance(c, dict)}
_selected = [
_active_map[cid] for cid in mcp_connector_ids if cid in _active_map
]
if _selected:
_connector_lines = []
for _c in _selected:
_tool_lines = []
for t in _c.get("tools", []):
_name = t.get("name", "unknown")
_desc = t.get("description", "") or "(no description)"
_args_schema = t.get("args", {}) or {}
# Render args schema as concise Parameters description
if _args_schema:
_param_parts = []
for _arg_name, _arg_meta in _args_schema.items():
if isinstance(_arg_meta, dict):
_arg_type = _arg_meta.get("type", "any")
_req = (
"required"
if _arg_meta.get("required")
else "optional"
)
_arg_desc = _arg_meta.get("description", "")
if _arg_desc:
_trimmed_desc = (
_arg_desc[:120] + "..."
if len(_arg_desc) > 120
else _arg_desc
)
_param_parts.append(
f'"{_arg_name}": <{_arg_type}, {_req}, '
f"{_trimmed_desc}>"
)
else:
_param_parts.append(
f'"{_arg_name}": <{_arg_type}, {_req}>'
)
else:
_param_parts.append(f'"{_arg_name}": <any>')
_params_str = "{" + ", ".join(_param_parts) + "}"
else:
_params_str = "{}"
_tool_lines.append(
f" - **{_name}**: {_desc}\n Parameters: {_params_str}"
)
_connector_lines.append(
f"### {_c.get('name', 'unknown')} "
f"({_c.get('connector_type', 'unknown')})\n"
f"{_c.get('description', '') or '(no description)'}\n\n"
f"Tools (call directly with `Action: <tool_name>`):\n"
+ "\n".join(_tool_lines)
+ "\n\nNote: Write operations require user confirmation."
)
_connector_prompt = (
"\n\n## Available MCP Connector Tools\n"
"You have access to the following external MCP connectors. "
"All listed tools are pre-registered — invoke them directly "
"with `Action: <tool_name>`, NOT through `execute_tool`.\n"
+ "\n\n".join(_connector_lines)
)
workflow_prompt += _connector_prompt
except Exception:
pass # graceful degradation
# --- End connector system prompt injection ---
if _graph_workspace:
_attached_links = None
if isinstance(dialogue.ext_info, dict):
_attached_links = dialogue.ext_info.get("organization_links")
if isinstance(_attached_links, dict):
from kamidso.data_graph.tools.graph_search import format_attached_links
workflow_prompt += format_attached_links(_attached_links)
_attached_facts = None
if isinstance(dialogue.ext_info, dict):
_attached_facts = dialogue.ext_info.get("organization_field_facts")
if isinstance(_attached_facts, dict):
from kamidso.data_graph.tools.graph_search import format_attached_field_facts
workflow_prompt += format_attached_field_facts(_attached_facts)
workflow_prompt += """
## Organization data graph
**search_organization_graph**: Find WHERE a concept lives. Returns field addresses and confirmed links only.
Parameters: {"query": "what the user is asking, including the entity name"}
Only confirmed_paths are real links. Suggested links are not facts.
If a section named "Confirmed links between attached sources" is present, those links are already facts. Use them first, and call this tool only for a source that is not attached.
**inspect_field_semantics**: Resolve WHAT an organization field means when the name, path, or stored meaning is unclear.
Parameters: {"field_ids": ["fld_..."], "question": "optional current user question"}
This tool does not compute business values. After meaning is known, read actual values with file, SQL, or API tools.
After addresses are known, read those sources with the existing file, SQL, or API tools.
## Field semantic resolution
Organization fields may already contain semantic labels and meanings.
Use field names, paths, stored meanings and confirmed graph links normally.
Do NOT call inspect_field_semantics for every field.
Call inspect_field_semantics only when:
1. a field required for the current task has no usable meaning;
2. the field name/path is ambiguous;
3. stored semantic status is unknown or needs_review;
4. two candidate fields could represent different business concepts;
5. the stored meaning conflicts with the current schema or observed values.
Pass only the ambiguous field ids.
Before asking the user what a field means, you MUST call inspect_field_semantics first.
If inspect_field_semantics still returns unknown, needs_user_input=true, or conflicting meanings, then use the question tool and ask the user a precise clarification.
Never invent the meaning of an unresolved field.
"""
if native_section:
workflow_prompt += "\n\n" + native_section.replace("{", "{{").replace("}", "}}")
workflow_prompt = (
f"{workflow_prompt.rstrip()}\n\n"
f"{_MULTI_TURN_PROMPT_SECTION}\n\n"
f"{_STEP_BUDGET_PROMPT_SECTION}"
)
# Convert workflow_prompt to PromptTemplate so it is used as system prompt
# Use jinja2 format to avoid issues with JSON braces { } in the prompt
workflow_prompt_template = PromptTemplate(
template=workflow_prompt,
input_variables=[],
template_format="jinja2",
)
agent_builder = (
ToolCallingReActAgent(
max_retry_count=90,
max_timeout=_REACT_AGENT_MAX_TIMEOUT_SECONDS,
)
.bind(context)
.bind(agent_memory)
.bind(llm_config)
.bind(tool_pack)
)
agent_builder = bind_agent_prompt(
agent_builder,
workflow_prompt_template,
extra_goal=native_section,
)
agent = await agent_builder.build()
parser = ReActOutputParser()
# 1) Fast deterministic rewrites for yes/آره and "گزارش بالا".
agent_question = _expand_followup_confirmation(user_input, historical_dialogues)
agent_question = _expand_report_reference(
agent_question,
historical_dialogues,
last_report=_kamidso_last_report,
)
# 2) Gated LLM context resolver for other history-dependent follow-ups
# (deixis / short continuations). Skipped when deterministic already
# expanded the question or the message is a standalone ask.
try:
from .context_resolver import resolve_conversation_intent
_report_snapshot = (
_format_report_for_history(_kamidso_last_report)
if _kamidso_last_report
else ""
)
agent_question = await resolve_conversation_intent(
user_input=user_input,
current_question=agent_question,
historical_dialogues=historical_dialogues,
llm_client=llm_client,
model_name=dialogue.model_name,
report_text=_report_snapshot or None,
)
except Exception: # noqa: BLE001 — never block the turn on resolver failure
logger.warning("context resolver failed; using prior question", exc_info=True)
received = AgentMessage(content=agent_question)
log_prompt_metadata(
conversation_id=conv_id,
question=agent_question,
task_progress=getattr(agent, "task_progress_summary", None),
observation_present=False,
round_num=0,
)
# stream_queue and stream_callback were created earlier (before ToolPack)
# so that the question tool can use them.
# Wire up context-management status events into the SSE stream.
async def _context_status_callback(status: Dict[str, Any]) -> None:
await stream_queue.put({"type": "context.status", **status})
agent.init_context_management(
config=await _load_context_budget_config(
llm_client=llm_client,
model_name=dialogue.model_name,
),
model_name=dialogue.model_name,
on_status_event=_context_status_callback,
)
async def run_agent():
return await agent.generate_reply(
received_message=received,
sender=agent,
stream_callback=stream_callback,
historical_dialogues=historical_dialogues,
)
agent_task = asyncio.create_task(run_agent())
if agent_task_holder is not None:
agent_task_holder.append(agent_task)
conv_id = react_state.get("conv_id")
if conv_id:
REACT_CONVERSATION_AGENT_TASKS[str(conv_id)] = agent_task
round_step_map: Dict[int, str] = {}
pending_thoughts: Dict[
int, List[str]
] = {} # Buffer thinking content for delayed step creation
pending_raw_stream: Dict[int, List[str]] = {}
streamed_display_text: Dict[int, str] = {}
pending_action_intentions: Dict[int, str] = {}
pending_action_reasons: Dict[int, str] = {}
# Emit a one-time "task preview" from the model's first "Plan: ..." line.
task_plan_emitted = False
# --- History persistence: collect step data during streaming ---
history_steps: List[Dict[str, Any]] = []
current_history_step: Optional[Dict[str, Any]] = None
subagent_history: Dict[str, Dict[str, Any]] = {}
final_answer_assembler = FinalAnswerAssembler()
fatal_llm_error: Optional[str] = None
if react_state.get("db_connection_give_up"):
fatal_llm_error = str(react_state["db_connection_give_up"])
if not agent_task.done():
agent_task.cancel()
while True:
if fatal_llm_error:
break
if agent_task.done() and stream_queue.empty():
break
try:
event = await asyncio.wait_for(stream_queue.get(), timeout=0.1)
except asyncio.TimeoutError:
continue
event_type = event.get("type")
if event_type == "context.status":
# Forward context-management status to frontend as-is.
yield _sse_event(event)
elif event_type in ("question.asked", "question.replied", "question.rejected"):
# Forward human-in-the-loop question events to frontend as-is.
yield _sse_event(event)
elif event_type in (
"agent.start",
"agent.done",
"agent.step",
"agent.thinking",
"subagent.artifacts",
):
# Sub-agent progress uses a separate event channel so parallel
# child rounds cannot collide with the lead agent's round map.
update_subagent_history(subagent_history, event)
yield _sse_event(event)
elif event_type == "thinking":
# Parse thinking content but don't create step yet
# Step will be created when 'act' event arrives with confirmed
# action
round_num = int(event.get("round") or (len(round_step_map) + 1))
llm_reply = event.get("llm_reply") or ""
if _is_fatal_llm_output(llm_reply):
fatal_llm_error = _fatal_llm_user_message(dialogue.ext_info)
if not agent_task.done():
agent_task.cancel()
break
thought = None
action_intention = None
action_reason = None
action = None
action_input = None
try:
steps = parser.parse(llm_reply)
if steps:
thought = steps[0].thought
action_intention = steps[0].action_intention
action_reason = steps[0].action_reason
action = steps[0].action
action_input = steps[0].action_input
except Exception:
pass
# Store parsed thinking info in pending_thoughts for later use
if round_num not in pending_thoughts:
pending_thoughts[round_num] = []
if thought:
pending_thoughts[round_num].append(thought)
intention_text = normalize_display_text(action_intention)
if intention_text:
pending_action_intentions[round_num] = intention_text
reason_text = normalize_display_text(action_reason)
if reason_text:
pending_action_reasons[round_num] = reason_text
# Don't emit anything yet - wait for 'act' event to create step
elif event_type == "thinking_chunk":
round_num = int(event.get("round") or (len(round_step_map) + 1))
delta_thinking = event.get("delta_thinking") or ""
delta_text = event.get("delta_text") or ""
chunk = delta_thinking or delta_text
if chunk and _is_fatal_llm_output(chunk):
fatal_llm_error = _fatal_llm_user_message(dialogue.ext_info)
if not agent_task.done():
agent_task.cancel()
break
if chunk:
if round_num not in pending_raw_stream:
pending_raw_stream[round_num] = []
pending_raw_stream[round_num].append(chunk)
raw_buf = "".join(pending_raw_stream[round_num])
# Rebuild the pure Thought buffer from the full stream so later
# Action Input fragments do not pollute step history.
thought_only = clean_thinking_chunk(raw_buf)
if thought_only:
pending_thoughts[round_num] = [thought_only]
display = live_thinking_display(raw_buf)
if display and round_num not in round_step_map:
pending_step_id, pending_step_event = build_step(
thinking_step_title,
"Thought/Action/Observation",
)
round_step_map[round_num] = pending_step_id
yield pending_step_event
yield _sse_event(
{
"type": "thinking.started",
"id": pending_step_id,
"round": round_num,
}
)
if not display:
continue
prev_text = streamed_display_text.get(round_num, "")
delta_display, replace_display = thinking_display_delta(
prev_text, display
)
streamed_display_text[round_num] = display
if delta_display or replace_display:
payload = {
"type": "thinking.delta",
"id": round_step_map.get(round_num),
"round": round_num,
"content": delta_display,
}
if replace_display:
payload["replace"] = True
yield _sse_event(payload)
elif event_type == "act":
# Create step ONLY when action is confirmed
round_num = int(event.get("round") or (len(round_step_map) + 1))
action_output = event.get("action_output") or {}
thoughts = action_output.get("thoughts")
action = action_output.get("action")
action_input = action_output.get("action_input")
action_input_data = None
if action_input is not None:
if isinstance(action_input, str):
try:
action_input_data = json.loads(action_input)
except Exception:
action_input_data = action_input
else:
action_input_data = action_input
# Skip step display for terminate action — its output will be
# sent as a streaming "final" event instead of a step card.
# Also skip emitting the thought for terminate since it's noise.
# Note: TerminateAction.run() sets terminate=True but does NOT
# set the action field, so we must check the terminate boolean.
is_terminate = action_output.get("terminate") or (
action and action.lower() == "terminate"
)
if is_terminate:
pending_thoughts.pop(round_num, None)
pending_raw_stream.pop(round_num, None)
streamed_display_text.pop(round_num, None)
pending_action_intentions.pop(round_num, None)
pending_action_reasons.pop(round_num, None)
# derisk 参考:terminate 是「模拟 action」,不展示为真实工具 step。
# 通知前端移除 terminate 这轮在 thinking_chunk 阶段提前创建的
# 「思考中」占位 step(前端 step.meta 里 action=terminate 会过滤)。
if round_num in round_step_map:
_stale_id = round_step_map.pop(round_num)
yield step_meta(_stale_id, None, "terminate", None, "terminate")
continue
# ── TodoWrite: emit plan.update SSE and show step card ──
if action and action.lower() == "todowrite":
pending_thoughts.pop(round_num, None)
pending_raw_stream.pop(round_num, None)
streamed_display_text.pop(round_num, None)
pending_action_intentions.pop(round_num, None)
pending_action_reasons.pop(round_num, None)
# Extract todos from observation JSON
obs_text = action_output.get("observations") or action_output.get(
"content"
)
todos_payload: List[Dict[str, str]] = []
if obs_text:
try:
obs_json = (
json.loads(obs_text)
if isinstance(obs_text, str)
else obs_text
)
if isinstance(obs_json, dict):
todos_payload = obs_json.get("__todos__", [])
except Exception:
pass
# Fallback: read from the closure variable
if not todos_payload and _todo_list:
todos_payload = list(_todo_list)
_td_total = len(todos_payload)
_td_done = sum(
1 for t in todos_payload if t.get("status") == "completed"
)
if _td_done == 0:
todo_state = "init"
elif _td_done == _td_total and _td_total > 0:
todo_state = "done"
else:
todo_state = "progress"
todo_meta = {
"state": todo_state,
"done": _td_done,
"total": _td_total,
}
_todo_step_title = (
f"TODO::{todo_state}:{_td_done}/{_td_total}"
if _td_total > 0
else f"TODO::{todo_state}"
)
# Emit or update the session-level task-plan card.
# NOTE: Do NOT set phase — let it fall into the default
# "Execution Steps" group so todowrite cards appear inline
# alongside other action steps in chronological order.
#
# The lead agent calls todowrite repeatedly (create plan,
# update progress, complete plan). All calls must update the
# same card instead of creating TODO::init/progress/done cards.
if _todo_step_holder:
todo_step_id = _todo_step_holder[0]
else:
todo_step_id, _ = build_step(
_todo_step_title,
"todowrite",
)
_todo_step_holder.append(todo_step_id)
round_step_map[round_num] = todo_step_id
yield _sse_event(
{
"type": "step.start",
"step": step,
"id": todo_step_id,
"title": _todo_step_title,
"detail": "todowrite",
"todo_meta": todo_meta,
}
)
yield _sse_event({"type": "plan.update", "tasks": todos_payload})
yield step_meta(
round_step_map[round_num],
None,
action,
None,
_todo_step_title,
todo_meta=todo_meta,
)
history_steps.append(
{
"id": round_step_map[round_num],
"title": _todo_step_title,
"detail": "todowrite",
"thought": None,
"action_intention": None,
"action_reason": None,
"action": action,
"action_input": None,
"outputs": [],
"status": "done",
"todo_meta": todo_meta,
}
)
yield step_done(round_step_map[round_num])
continue
# Collect buffered thoughts for history persistence
# (already streamed to frontend via thinking_chunk handler)
buffered_thoughts = pending_thoughts.pop(round_num, [])
pending_raw_stream.pop(round_num, None)
streamed_display_text.pop(round_num, None)
thought_text = None
if buffered_thoughts:
full_thought = "".join(buffered_thoughts)
full_thought = re.split(r"\n\s*Action\s*:", full_thought, maxsplit=1)[
0
].strip()
if full_thought.startswith("Thought:"):
full_thought = full_thought[len("Thought:") :].strip()
if full_thought:
thought_text = full_thought
action_intention = normalize_display_text(
action_output.get("action_intention")
or pending_action_intentions.pop(round_num, None)
or action_output.get("phase")
)
# derisk-style task preview: the first action's notion/intention is a
# one-time "接下来要做" overview shown to the user before execution.
if not task_plan_emitted and action_intention:
task_plan_emitted = True
yield _sse_event(
{
"type": "task.preview",
"content": action_intention,
"round": round_num,
}
)
action_reason = normalize_display_text(
action_output.get("action_reason")
or pending_action_reasons.pop(round_num, None)
)
display_thought = (
normalize_display_text(thoughts or thought_text) or action_intention
)
# 解析失败轮次(如 native tool_calls 退回 XML 导致 "No valid ReAct
# step found"):action 为空、无有效工具,跳过 step 展示(derisk 参考:
# 无效 action 不展示为噪音 step),仅清理提前创建的「思考中」占位。
if not action:
pending_thoughts.pop(round_num, None)
pending_raw_stream.pop(round_num, None)
streamed_display_text.pop(round_num, None)
pending_action_intentions.pop(round_num, None)
pending_action_reasons.pop(round_num, None)
if round_num in round_step_map:
_stale_id = round_step_map.pop(round_num)
yield _sse_event(
{"type": "step.done", "id": _stale_id, "status": "done"}
)
continue
# Tool slug stays in `action`; user-facing title prefers intention.
action_title = action or f"ReAct Round {round_num}"
display_title = action_intention or action_title
if round_num in round_step_map:
# Step already exists (from thinking) - update title with same id
react_step_id = round_step_map[round_num]
updated_event = _sse_event(
{
"type": "step.start",
"step": step,
"id": react_step_id,
"title": display_title,
"action": action,
"action_intention": action_intention,
"detail": "Thought/Action/Observation",
}
)
yield updated_event
else:
react_step_id, react_step_event = build_step(
display_title,
"Thought/Action/Observation",
action=action,
action_intention=action_intention,
)
round_step_map[round_num] = react_step_id
yield react_step_event
# --- History: create step record ---
action_input_str = None
if action_input is not None:
action_input_str = (
action_input
if isinstance(action_input, str)
else json.dumps(action_input, ensure_ascii=False)
)
current_history_step = {
"id": react_step_id,
"title": display_title,
"detail": "Thought/Action/Observation",
"thought": display_thought,
"action_intention": action_intention,
"action_reason": action_reason,
"action": action,
"action_input": action_input_str,
"outputs": [],
"status": "running",
}
# Stream action code to frontend for right panel
# (code_interpreter)
code_payload = None
if action == "code_interpreter" and isinstance(action_input_data, dict):
code_payload = action_input_data.get("code")
if isinstance(code_payload, str) and code_payload.strip():
yield step_chunk(react_step_id, "code", code_payload)
if current_history_step is not None:
current_history_step["outputs"].append(
{"output_type": "code", "content": code_payload}
)
# Emit thinking metadata
if thoughts or action or action_input:
step_action_input = (
None if action == "code_interpreter" else action_input
)
yield step_meta(
react_step_id,
display_thought,
action,
step_action_input,
display_title,
action_intention=action_intention,
action_reason=action_reason,
)
# Emit observation (action execution result)
observation_text = action_output.get("observations") or action_output.get(
"content"
)
status = "done" if action_output.get("is_exe_success", True) else "failed"
if observation_text:
raw_chunks = emit_tool_chunks(react_step_id, observation_text)
if raw_chunks:
for chunk in raw_chunks:
yield chunk
else:
for chunk in chunk_text(str(observation_text), max_len=600):
yield step_chunk(react_step_id, "text", chunk)
# --- History: collect the tool output for replay ---
if current_history_step is not None:
parsed_obs = None
if isinstance(observation_text, str):
try:
parsed_obs = json.loads(observation_text)
except Exception:
pass
if isinstance(parsed_obs, dict) and isinstance(
parsed_obs.get("chunks"), list
):
for item in parsed_obs["chunks"]:
if isinstance(item, dict):
content = item.get("content")
if isinstance(content, str):
content = content.strip()
current_history_step["outputs"].append(
{
"output_type": item.get("output_type", "text"),
"content": content,
}
)
elif isinstance(observation_text, str) and observation_text:
current_history_step["outputs"].append(
{
"output_type": "text",
"content": observation_text,
}
)
# Citations are opt-in: the assembler accepts only explicitly
# supported knowledge tools and fails closed on malformed data.
final_answer_assembler.observe(
action,
action_input_data,
observation_text,
succeeded=status == "done",
)
# Mark step as done and track as last completed
yield step_done(react_step_id, status)
if (
status == "done"
and action
and action.lower() != "todowrite"
and should_advance_todo(
action, thought_text or thoughts, observation_text
)
):
updated_todos = advance_todo_list()
if updated_todos:
yield _sse_event({"type": "plan.update", "tasks": updated_todos})
# --- History: finalize step ---
if current_history_step is not None:
current_history_step["status"] = status
history_steps.append(current_history_step)
current_history_step = None
if observation_text and not fatal_llm_error:
from kamidso.database_runtime.errors import (
note_database_connection_failure,
)
observed = (
observation_text
if isinstance(observation_text, str)
else json.dumps(observation_text, ensure_ascii=False, default=str)
)
give_up = note_database_connection_failure(
react_state,
observed,
language=_response_language(dialogue.ext_info),
)
if give_up:
fatal_llm_error = give_up
if not agent_task.done():
agent_task.cancel()
break
if fatal_llm_error:
err_msg = fatal_llm_error
fail_running_subagent_history(subagent_history)
error_payload = _build_react_history_payload(
final_content=err_msg,
steps=history_steps,
task_plan=list(_todo_list),
generated_images=react_state.get("generated_images", []),
sub_agents=build_subagent_history_snapshot(subagent_history),
input_files=input_files_snapshot,
citations=[],
)
for terminal_event in _react_terminal_events(
storage_conv,
error_payload,
AgentFinalAnswer(content=err_msg),
):
yield terminal_event
return
try:
reply = await agent_task
except asyncio.CancelledError:
if not fatal_llm_error:
# User stop: do not write this round into agent/chat history.
return
err_msg = fatal_llm_error
fail_running_subagent_history(subagent_history)
error_payload = _build_react_history_payload(
final_content=err_msg,
steps=history_steps,
task_plan=list(_todo_list),
generated_images=react_state.get("generated_images", []),
sub_agents=build_subagent_history_snapshot(subagent_history),
input_files=input_files_snapshot,
citations=[],
)
for terminal_event in _react_terminal_events(
storage_conv,
error_payload,
AgentFinalAnswer(content=err_msg),
):
yield terminal_event
return
except Exception as e:
err_msg = f"React agent failed: {e}"
fail_running_subagent_history(subagent_history)
error_payload = _build_react_history_payload(
final_content=err_msg,
steps=history_steps,
task_plan=list(_todo_list),
generated_images=react_state.get("generated_images", []),
sub_agents=build_subagent_history_snapshot(subagent_history),
input_files=input_files_snapshot,
citations=[],
)
for terminal_event in _react_terminal_events(
storage_conv,
error_payload,
AgentFinalAnswer(content=err_msg),
):
yield terminal_event
return
if reply.action_report and reply.action_report.terminate:
raw_content = reply.action_report.content or ""
# Prefer the user-facing terminate result; salvage truncated JSON /
# single-line ReAct envelopes so protocol text never leaks into chat.
from kamidso.engine.app.openapi.api_v1.thinking_stream import (
extract_terminate_user_answer,
)
final_content = extract_terminate_user_answer(raw_content) or raw_content
if final_content == raw_content and (
"Thought:" in raw_content or "Action Input:" in raw_content
):
final_content = re.sub(
r"(?m)^(Thought|Action|Action Input|Observation|Phase):\s*",
"",
raw_content,
).strip() or raw_content
elif reply.action_report:
# Loop ended without terminate (max retries or timeout).
# reply.content is raw LLM output containing ReAct prefixes.
# Try to extract a clean summary from the last step's thought.
raw = reply.content or reply.action_report.content or ""
final_content = raw
try:
steps = parser.parse(raw)
if steps:
last_step = steps[-1]
# Prefer observation (execution result) > thought
if last_step.observations:
final_content = last_step.observations
elif last_step.thoughts:
final_content = last_step.thoughts
except Exception:
pass
# Fallback: strip remaining ReAct prefixes via regex
final_content = re.sub(
r"^(Thought|Action|Action Input|Observation|Phase):\s*",
"",
final_content,
flags=re.MULTILINE,
).strip()
if not final_content:
final_content = "任务执行已达到最大步数限制,请查看上方各步骤的执行结果。"
else:
final_content = reply.content or ""
final_answer = final_answer_assembler.finalize(final_content)
# Persist AI reply with structured history payload
history_payload = _build_react_history_payload(
final_content=final_answer.content,
steps=history_steps,
task_plan=list(_todo_list),
generated_images=react_state.get("generated_images", []),
sub_agents=build_subagent_history_snapshot(subagent_history),
input_files=input_files_snapshot,
citations=[citation.to_dict() for citation in final_answer.citations],
)
for terminal_event in _react_terminal_events(
storage_conv,
history_payload,
final_answer,
):
yield terminal_event
# ---------------------------------------------------------------------------
# Share link APIs
# ---------------------------------------------------------------------------
class ShareCreateRequest(_BaseModel):
"""Request body for creating a share link."""
conv_uid: str
class ShareCreateResponse(_BaseModel):
"""Response body for share link creation."""
token: str
conv_uid: str
share_url: str
class ShareConvResponse(_BaseModel):
"""Public payload returned when viewing a shared conversation."""
conv_uid: str
token: str
messages: list # list[{role, context, order}]
def _get_share_dao():
"""Lazily instantiate the ShareLinkDao (avoids import-time side-effects)."""
from kamidso.engine.app.share.models import ShareLinkDao
return ShareLinkDao()
def _get_conversation_service():
"""Return the ConversationServe Service component."""
from kamidso.engine.serve.conversation.config import SERVE_SERVICE_COMPONENT_NAME
from kamidso.engine.serve.conversation.service.service import Service
return CFG.SYSTEM_APP.get_component(SERVE_SERVICE_COMPONENT_NAME, Service)
def _conversation_owner_user_name(conv_uid: str) -> Optional[str]:
"""Return the recorded owner (``user_name``) of a conversation.
``None`` means the conversation does not exist, so callers can fail
closed. Legacy anonymous conversations return the stored blank value and
keep their previous share behavior.
"""
from kamidso.engine.core.storage.chat_history.chat_history_db import ChatHistoryEntity
service = _get_conversation_service()
with service.dao.session(commit=False) as session:
entity = (
session.query(ChatHistoryEntity)
.filter(ChatHistoryEntity.conv_uid == conv_uid)
.first()
)
return None if entity is None else (entity.user_name or "")
@router.post("/v1/chat/share", response_model=Result)
async def create_share_link(
body: ShareCreateRequest = Body(),
user_token: UserRequest = Depends(get_user_from_headers),
):
"""Create (or return existing) share link for a conversation.
The returned ``share_url`` is a relative path that the client should
prepend with the current host to form an absolute URL.
Ownership is verified first: a conversation recorded for another user
cannot be shared by a foreign (or anonymous) caller, and a conversation
that does not exist cannot be shared at all.
"""
from fastapi import HTTPException
requester = user_token.user_id if user_token else None
owner = _conversation_owner_user_name(body.conv_uid)
if owner is None:
raise HTTPException(status_code=404, detail="Conversation not found")
if owner and owner != (requester or ""):
raise HTTPException(status_code=403, detail="Not the conversation owner")
dao = _get_share_dao()
entity = dao.create_share(conv_uid=body.conv_uid, created_by=requester)
if entity is None:
return Result.failed(msg="Failed to create share link")
return Result.succ(
ShareCreateResponse(
token=entity.token,
conv_uid=entity.conv_uid,
share_url=f"/share/{entity.token}",
)
)
@router.get("/v1/chat/share/{token}", response_model=Result)
async def get_share_conversation(token: str):
"""Public endpoint — no authentication required.
Returns the full conversation history for the given share token so that the
replay page can reconstruct and animate the session.
"""
dao = _get_share_dao()
link = dao.get_by_token(token)
if link is None:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Share link not found")
service = _get_conversation_service()
from kamidso.engine.serve.conversation.api.schemas import ServeRequest
history = service.get_history_messages(ServeRequest(conv_uid=link.conv_uid))
# Public viewers get scrubbed react history: v2 payloads have their
# ``input_files`` rewritten to non-resolvable public snapshots; v1
# payloads and plain-text messages are passed through unchanged.
messages = [
{
"role": m.role,
"context": scrub_react_history_for_share(m.context),
"order": m.order,
}
for m in (history or [])
]
return Result.succ(
ShareConvResponse(
conv_uid=link.conv_uid,
token=token,
messages=messages,
)
)
@router.delete("/v1/chat/share/{token}", response_model=Result)
async def delete_share_link(
token: str,
user_token: UserRequest = Depends(get_user_from_headers),
):
"""Revoke a share link.
Only the recorded creator may delete a link; legacy anonymous shares
(no recorded creator) remain revocable by anyone. Foreign users get a
403 and unknown tokens a 404 — no share is silently dropped.
"""
from fastapi import HTTPException
dao = _get_share_dao()
link = dao.get_by_token(token)
if link is None:
raise HTTPException(status_code=404, detail="Share link not found")
requester = user_token.user_id if user_token else None
if link.created_by and link.created_by != (requester or ""):
raise HTTPException(status_code=403, detail="Not the share owner")
deleted = dao.delete_by_token(token)
if not deleted:
raise HTTPException(status_code=404, detail="Share link not found")
return Result.succ({"deleted": True, "token": token})
@router.get("/v1/agent/files/download")
async def download_agent_file(
file_path: str = Query(..., description="Absolute path to the file to download"),
):
"""Download a file created by agent tools (shell_interpreter, code_interpreter).
Only files under allowed directories (/tmp, PILOT_PATH/tmp/) can be downloaded.
This prevents arbitrary file access on the server.
"""
from fastapi import HTTPException
from fastapi.responses import FileResponse
from kamidso.engine.core.configs.model_config import PILOT_PATH, ROOT_PATH
# If path is not absolute, resolve relative to ROOT_PATH (sandbox working dir)
if not os.path.isabs(file_path):
file_path = os.path.join(ROOT_PATH, file_path)
# Resolve to absolute path and prevent path traversal
try:
resolved = os.path.realpath(file_path)
except (ValueError, OSError):
raise HTTPException(status_code=400, detail="Invalid file path")
# Allowed base directories for agent-created files
allowed_dirs = [
os.path.realpath("/tmp"),
os.path.realpath(os.path.join(PILOT_PATH, "tmp")),
os.path.realpath(ROOT_PATH),
]
if not any(resolved.startswith(d + os.sep) or resolved == d for d in allowed_dirs):
raise HTTPException(
status_code=403,
detail="Access denied: file is not in an allowed directory",
)
if not os.path.isfile(resolved):
raise HTTPException(status_code=404, detail="File not found")
filename = os.path.basename(resolved)
return FileResponse(
path=resolved,
filename=filename,
media_type="application/octet-stream",
)
@router.get("/v1/agent/skills/download")
async def download_skill_package(
skill_name: str = Query(..., description="Skill folder name"),
user_token: UserRequest = Depends(get_user_from_headers),
):
"""Download a skill folder as a .zip archive."""
from fastapi import HTTPException
if not skill_name:
raise HTTPException(status_code=400, detail="skill_name is required")
skills_dir = Path(DEFAULT_SKILLS_DIR).expanduser().resolve()
skill_path = (skills_dir / skill_name).resolve()
# Security: ensure path is under skills_dir
try:
skill_path.relative_to(skills_dir)
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
if not skill_path.is_dir():
raise HTTPException(status_code=404, detail="Skill not found")
# Build zip in memory
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for root, _dirs, files in os.walk(skill_path):
for fname in files:
abs_file = os.path.join(root, fname)
arc_name = os.path.relpath(abs_file, skill_path)
zf.write(abs_file, arcname=os.path.join(skill_name, arc_name))
buf.seek(0)
return StreamingResponse(
buf,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{skill_name}.zip"',
},
)
@router.post("/v1/chat/react-agent")
async def chat_react_agent(
dialogue: ConversationVo = Body(),
user_token: UserRequest = Depends(get_user_from_headers),
):
logger.info(
"chat_react_agent:%s,%s,%s",
dialogue.chat_mode,
dialogue.select_param,
dialogue.model_name,
)
dialogue.user_name = user_token.user_id if user_token else dialogue.user_name
# Pre-flight: 400/404 surface before the stream (and agent) is built.
attachment_ctx = await _open_turn_attachments(dialogue, user_token)
headers = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Transfer-Encoding": "chunked",
}
try:
return _AgentStreamingResponse(
_react_agent_stream(
dialogue, tool_mode="full", attachment_ctx=attachment_ctx
),
headers=headers,
media_type="text/event-stream",
)
except Exception as e:
# The streaming generator never started, so its own cleanup never
# ran — drop the materialized turn files here before erroring out.
_close_turn_attachments_quietly(attachment_ctx)
logger.exception("React Agent Exception!%s", dialogue, exc_info=e)
async def error_text(err_msg):
yield f"data:{err_msg}\n\n"
return StreamingResponse(
error_text(str(e)),
headers=headers,
media_type="text/plain",
)
@router.post("/v1/chat/knowledge-agent")
async def chat_knowledge_agent(
dialogue: ConversationVo = Body(),
user_token: UserRequest = Depends(get_user_from_headers),
):
"""Knowledge-base chat agent — only kb tools + todowrite + terminate.
Optimized for pure knowledge-chat scenarios (no skill/shell/sql/html tools).
"""
logger.info(
"chat_knowledge_agent:%s,%s,%s",
dialogue.chat_mode,
dialogue.select_param,
dialogue.model_name,
)
dialogue.user_name = user_token.user_id if user_token else dialogue.user_name
# Pre-flight: 400/404 surface before the stream (and agent) is built.
attachment_ctx = await _open_turn_attachments(dialogue, user_token)
headers = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Transfer-Encoding": "chunked",
}
try:
return _AgentStreamingResponse(
_react_agent_stream(
dialogue, tool_mode="knowledge", attachment_ctx=attachment_ctx
),
headers=headers,
media_type="text/event-stream",
)
except Exception as e:
# The streaming generator never started, so its own cleanup never
# ran — drop the materialized turn files here before erroring out.
_close_turn_attachments_quietly(attachment_ctx)
logger.exception("Knowledge Agent Exception!%s", dialogue, exc_info=e)
async def error_text(err_msg):
yield f"data:{err_msg}\n\n"
return StreamingResponse(
error_text(str(e)),
headers=headers,
media_type="text/plain",
)
# ── Human-in-the-Loop Question API ──────────────────────────────────────────
class _QuestionReplyBody(_BaseModel):
answers: List[List[str]]
@router.post("/v1/chat/question/{request_id}/reply", response_model=Result)
async def question_reply(
request_id: str,
body: _QuestionReplyBody,
user_token: UserRequest = Depends(get_user_from_headers),
):
"""User submits answers to a pending question, unblocking the agent tool."""
from kamidso.engine.app.openapi.api_v1.tools.question_manager import question_manager
try:
question_manager.reply(request_id, body.answers)
return Result.succ({"success": True, "request_id": request_id})
except KeyError as e:
return Result.failed(msg=str(e))
@router.post("/v1/chat/question/{request_id}/reject", response_model=Result)
async def question_reject(
request_id: str,
user_token: UserRequest = Depends(get_user_from_headers),
):
"""User dismisses a pending question, unblocking the agent tool with rejection."""
from kamidso.engine.app.openapi.api_v1.tools.question_manager import question_manager
try:
question_manager.reject(request_id)
return Result.succ({"success": True, "request_id": request_id})
except KeyError as e:
return Result.failed(msg=str(e))