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>
5385 lines
220 KiB
Python
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))
|