Add native Didar connectors so chats can query CRM data reliably.

Connector calls, semantic rules, and DuckDB memory keep long runs stable, and stream retry stops chats from stalling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
javadandCursor committed 2026-10-12 19:26:47 +03:30
1 parent 228324db7b
commit edbaa2acec
82 files changed
+13427 -203

No files matched your search

+2
View File
@@ -5,6 +5,7 @@ from fastapi import FastAPI
from kamidso.api.routes import ( from kamidso.api.routes import (
auth, auth,
chat, chat,
connectors,
conversations, conversations,
datasources, datasources,
files, files,
@@ -26,6 +27,7 @@ def register_routes(app: FastAPI, *, prefix: str) -> None:
app.include_router(runs.router, prefix=prefix, tags=["runs"]) app.include_router(runs.router, prefix=prefix, tags=["runs"])
app.include_router(files.router, prefix=prefix, tags=["files"]) app.include_router(files.router, prefix=prefix, tags=["files"])
app.include_router(datasources.router, prefix=prefix, tags=["datasources"]) app.include_router(datasources.router, prefix=prefix, tags=["datasources"])
app.include_router(connectors.router, prefix=prefix, tags=["connectors"])
app.include_router(knowledge.router, prefix=prefix, tags=["knowledge"]) app.include_router(knowledge.router, prefix=prefix, tags=["knowledge"])
app.include_router(resources.router, prefix=prefix, tags=["resources"]) app.include_router(resources.router, prefix=prefix, tags=["resources"])
app.include_router(organization.router, prefix=prefix, tags=["organization"]) app.include_router(organization.router, prefix=prefix, tags=["organization"])
+224
View File
@@ -0,0 +1,224 @@
"""Product API for predefined native connectors. Users cannot define providers."""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from kamidso.api.deps import require_user
from kamidso.engine.serve.core import blocking_func_to_async
from kamidso.native_connectors.flags import (
didar_connector_enabled,
native_connectors_v1_enabled,
)
from kamidso.native_connectors.ux import (
connector_test_message,
public_connector,
public_provider,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _ok(data: Any) -> Dict[str, Any]:
return {"success": True, "data": data, "err_code": None, "err_msg": None}
def _native_enabled() -> bool:
return native_connectors_v1_enabled() and didar_connector_enabled()
def _service(request: Request):
from kamidso.engine.serve.connector.config import SERVE_SERVICE_COMPONENT_NAME
from kamidso.engine.serve.connector.service.service import ConnectorService
runtime = getattr(request.app.state.kamidso, "engine_runtime", None)
system_app = getattr(runtime, "system_app", None) if runtime is not None else None
if system_app is None:
raise HTTPException(status_code=503, detail="موتور اتصال‌ها آماده نیست.")
service = system_app.get_component(
SERVE_SERVICE_COMPONENT_NAME,
ConnectorService,
default_component=None,
)
if service is None:
raise HTTPException(status_code=503, detail="موتور اتصال‌ها آماده نیست.")
return service, system_app
def _owned(service, connector_id: str, user):
row = service.get_connector(connector_id)
if row is None or row.status == "deleted":
raise HTTPException(status_code=404, detail="اتصال پیدا نشد.")
if row.user_name != user.id:
raise HTTPException(status_code=404, detail="اتصال پیدا نشد.")
if user.workspace_id and row.sys_code and row.sys_code != user.workspace_id:
raise HTTPException(status_code=404, detail="اتصال پیدا نشد.")
if not service._is_native_type(row.connector_type):
raise HTTPException(status_code=404, detail="اتصال پیدا نشد.")
return row
class ConnectorCreateBody(BaseModel):
connector_type: str
display_name: str
credentials: Dict[str, str] = Field(default_factory=dict)
class ConnectorTestBody(BaseModel):
connector_type: str
credentials: Dict[str, str] = Field(default_factory=dict)
class ConnectorUpdateBody(BaseModel):
display_name: Optional[str] = None
credentials: Optional[Dict[str, str]] = None
def _probe_http(exc: Exception) -> HTTPException:
from kamidso.engine.serve.connector.service.service import ConnectorProbeFailed
if isinstance(exc, ConnectorProbeFailed):
public = connector_test_message(
{
"success": False,
"status_code": exc.status_code,
"error_type": exc.error_type,
}
)
return HTTPException(status_code=400, detail=public["message"])
text = str(exc)
if text.startswith("Missing credential"):
return HTTPException(status_code=400, detail="کلید API را وارد کنید.")
if "ENCRYPT_KEY" in text:
return HTTPException(
status_code=400,
detail="ذخیرهٔ امن کلید API در این محیط پیکربندی نشده است.",
)
if text == "Unknown native provider":
return HTTPException(status_code=404, detail="این اتصال در دسترس نیست.")
return HTTPException(status_code=400, detail="ارتباط با دیدار برقرار نشد.")
@router.get("/connectors/providers")
async def list_providers(request: Request):
require_user(request)
if not _native_enabled():
return _ok([])
from kamidso.native_connectors.registry import get_registry
registry = get_registry()
return _ok([public_provider(registry.get(item)) for item in registry.provider_ids()])
@router.get("/connectors")
async def list_user_connectors(request: Request):
user = require_user(request)
if not _native_enabled():
return _ok([])
service, _system = _service(request)
rows = service.list_connectors(
user_name=user.id,
sys_code=user.workspace_id or None,
)
visible: List[Dict[str, Any]] = []
for row in rows:
if row.status == "deleted" or not service._is_native_type(row.connector_type):
continue
visible.append(public_connector(row))
return _ok(visible)
@router.post("/connectors/test")
async def test_new_connector(body: ConnectorTestBody, request: Request):
require_user(request)
if not _native_enabled():
raise HTTPException(status_code=404, detail="این اتصال در دسترس نیست.")
service, _system = _service(request)
try:
raw = await service.test_native_credentials(body.connector_type, body.credentials)
except Exception as exc:
logger.exception("Native connector probe failed")
raise _probe_http(exc) from exc
return _ok(connector_test_message(raw))
@router.post("/connectors")
async def create_user_connector(body: ConnectorCreateBody, request: Request):
user = require_user(request)
if not _native_enabled():
raise HTTPException(status_code=404, detail="این اتصال در دسترس نیست.")
from kamidso.engine.serve.connector.service.service import ConnectorCreateRequest
service, system_app = _service(request)
if not service._is_native_type(body.connector_type):
raise HTTPException(status_code=404, detail="این اتصال در دسترس نیست.")
request_model = ConnectorCreateRequest(
connector_type=body.connector_type,
display_name=body.display_name.strip() or "دیدار CRM",
credentials=dict(body.credentials),
user_name=user.id,
sys_code=user.workspace_id or None,
)
try:
created = await blocking_func_to_async(
system_app, service.create_connector, request_model
)
except Exception as exc:
logger.exception("Create native connector failed")
raise _probe_http(exc) from exc
return _ok(public_connector(created))
@router.patch("/connectors/{connector_id}")
async def update_user_connector(
connector_id: str, body: ConnectorUpdateBody, request: Request
):
user = require_user(request)
from kamidso.engine.serve.connector.service.service import ConnectorUpdateRequest
service, system_app = _service(request)
_owned(service, connector_id, user)
credentials = None
if body.credentials:
filled = {
key: value
for key, value in body.credentials.items()
if str(value).strip()
}
credentials = filled or None
update = ConnectorUpdateRequest(
display_name=body.display_name.strip() if body.display_name else None,
credentials=credentials,
)
try:
updated = await blocking_func_to_async(
system_app, service.update_connector, connector_id, update
)
except Exception as exc:
logger.exception("Update native connector failed")
raise _probe_http(exc) from exc
return _ok(public_connector(updated))
@router.delete("/connectors/{connector_id}")
async def delete_user_connector(connector_id: str, request: Request):
user = require_user(request)
service, system_app = _service(request)
_owned(service, connector_id, user)
await blocking_func_to_async(system_app, service.retire_connector, connector_id)
return _ok({"ok": True})
@router.post("/connectors/{connector_id}/test")
async def test_saved_connector(connector_id: str, request: Request):
user = require_user(request)
service, _system = _service(request)
_owned(service, connector_id, user)
raw = await service.test_connection(connector_id)
return _ok(connector_test_message(raw))
+15 -2
View File
@@ -5,6 +5,19 @@ from fastapi import APIRouter, Request
router = APIRouter() router = APIRouter()
def _features_payload(settings) -> dict:
flags = settings.features.to_dict()
from kamidso.native_connectors.flags import (
didar_connector_enabled,
native_connectors_v1_enabled,
)
flags["connectors"] = bool(
native_connectors_v1_enabled() and didar_connector_enabled()
)
return flags
@router.get("/health") @router.get("/health")
async def health(request: Request): async def health(request: Request):
app = request.app.state.kamidso app = request.app.state.kamidso
@@ -25,7 +38,7 @@ async def product(request: Request):
"product": settings.product.name, "product": settings.product.name,
"locale": settings.product.locale, "locale": settings.product.locale,
"direction": settings.product.direction, "direction": settings.product.direction,
"features": settings.features.to_dict(), "features": _features_payload(settings),
"developer_mode": settings.developer_mode, "developer_mode": settings.developer_mode,
} }
@@ -34,7 +47,7 @@ async def product(request: Request):
async def features(request: Request): async def features(request: Request):
settings = request.app.state.kamidso.settings settings = request.app.state.kamidso.settings
user = getattr(request.state, "kamidso_user", None) user = getattr(request.state, "kamidso_user", None)
flags = settings.features.to_dict() flags = _features_payload(settings)
role = user.role if user else "user" role = user.role if user else "user"
if role == "user" and not settings.developer_mode: if role == "user" and not settings.developer_mode:
flags["apps"] = False flags["apps"] = False
+2 -1
View File
@@ -5,9 +5,10 @@ from __future__ import annotations
import asyncio import asyncio
from typing import AsyncIterator from typing import AsyncIterator
# Do not send Connection. It is hop-by-hop; proxies that honor it reuse the
# socket while the body is still streaming and the next chat drops both streams.
SSE_HEADERS = { SSE_HEADERS = {
"Cache-Control": "no-cache, no-transform", "Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", "X-Accel-Buffering": "no",
} }
+34 -1
View File
@@ -74,6 +74,12 @@ class KamidsoContextBuilder:
skills.append(skill) skills.append(skill)
behavior = DEFAULT_BEHAVIOR behavior = DEFAULT_BEHAVIOR
connector_ids = ext_info.get("connector_ids")
attached_connectors = (
[str(item).strip() for item in connector_ids if str(item).strip()]
if isinstance(connector_ids, list)
else []
)
kb_label = str(ext_info.get("knowledge_space_label") or (knowledge[0] if knowledge else "")).strip() kb_label = str(ext_info.get("knowledge_space_label") or (knowledge[0] if knowledge else "")).strip()
if knowledge: if knowledge:
behavior += ( behavior += (
@@ -135,13 +141,40 @@ class KamidsoContextBuilder:
behavior += ( behavior += (
f" Optional picker hint: `{skill}` — load it only if it fits." f" Optional picker hint: `{skill}` — load it only if it fits."
) )
else: elif not attached_connectors:
behavior += ( behavior += (
"\nNo file or database is attached this turn. " "\nNo file or database is attached this turn. "
"Do not reopen Excel/CSV files from earlier chats " "Do not reopen Excel/CSV files from earlier chats "
"(data/runtime, data/uploads, previous run folders). " "(data/runtime, data/uploads, previous run folders). "
"If the question needs data, call the question tool and ask which file or database to use." "If the question needs data, call the question tool and ask which file or database to use."
) )
if attached_connectors:
behavior += (
"\nA native business connector is attached. "
"Use connector_call to fetch business data. "
"Do not use code_interpreter to call the external API. "
"Use code_interpreter only after connector_call when computation, "
"dataframe operations, merging, charts, statistics or export are required. "
"Never invent connector function names. "
"On validation_error, correct the request using the returned schema "
"and retry the same function. "
"Prefer server-provided totals and aggregates. "
"Do not paginate through all records merely to count them."
)
if datasources or files:
behavior += (
" Didar and other native connectors use connector_call. "
"Databases use database(). Excel and CSV files use code_interpreter. "
"Join the results in code_interpreter after each source has been read."
)
labels = ext_info.get("connector_labels")
if isinstance(labels, list) and any(str(item).strip() for item in labels):
shown = ", ".join(str(item).strip() for item in labels if str(item).strip())
else:
shown = ", ".join(attached_connectors)
behavior += (
f" Attached connector{'s' if len(attached_connectors) > 1 else ''}: {shown}."
)
if skill: if skill:
behavior += ( behavior += (
f"\nThe user highlighted skill `{skill}` in the picker. " f"\nThe user highlighted skill `{skill}` in the picker. "
+1 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from typing import Any, Dict, Literal, Optional from typing import Any, Dict, Literal, Optional
ResourceType = Literal["file", "datasource", "knowledge"] ResourceType = Literal["file", "datasource", "knowledge", "connector"]
@dataclass(frozen=True) @dataclass(frozen=True)
-1
View File
@@ -87,7 +87,6 @@ async def list_edges(request: Request):
_SSE_HEADERS = { _SSE_HEADERS = {
"Cache-Control": "no-cache, no-transform", "Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", "X-Accel-Buffering": "no",
} }
@@ -34,6 +34,13 @@ from kamidso.engine.app.openapi.api_v1.native_dbgpt import (
prepare_react_gpts_memory, # noqa: F401 — re-export for tests prepare_react_gpts_memory, # noqa: F401 — re-export for tests
use_native_dbgpt_prompt, 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 ( from kamidso.engine.app.openapi.api_view_model import (
ConversationVo, ConversationVo,
Result, Result,
@@ -84,12 +91,6 @@ if TYPE_CHECKING:
from kamidso.engine.core.agent.resource.connector.manager import ConnectorManager from kamidso.engine.core.agent.resource.connector.manager import ConnectorManager
from kamidso.engine.core.agent.resource.tool.base import BaseTool from kamidso.engine.core.agent.resource.tool.base import BaseTool
REACT_AGENT_MEMORY_CACHE: Dict[str, "GptsMemory"] = {}
# Serialize ReAct runs per conversation. A second message is rejected while a
# run is live; explicit cancel uses REACT_CONVERSATION_AGENT_TASKS.
REACT_CONVERSATION_LOCKS: Dict[str, asyncio.Lock] = {}
REACT_CONVERSATION_AGENT_TASKS: Dict[str, "asyncio.Task[Any]"] = {}
_FATAL_LLM_ERROR_MARKERS = ("LLMServer Generate Error", "proxy_llm_client") _FATAL_LLM_ERROR_MARKERS = ("LLMServer Generate Error", "proxy_llm_client")
_REACT_SCRATCHPAD_MARKERS = ("Thought:", "Action:", "Action Input:", "Observation:") _REACT_SCRATCHPAD_MARKERS = ("Thought:", "Action:", "Action Input:", "Observation:")
@@ -2133,64 +2134,64 @@ async def _react_agent_stream_inner(
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""Stream ReAct events while owning the lifetime of its background task.""" """Stream ReAct events while owning the lifetime of its background task."""
conv_id = getattr(dialogue, "conv_uid", None) or "" conv_id = getattr(dialogue, "conv_uid", None) or ""
conv_lock = _conversation_agent_lock(conv_id) if conv_id else None # Same conversation is serialized by this lock. A second message waits on
if conv_lock is not None: # the same lock object; the lock is removed only after every waiter exits.
await conv_lock.acquire() # Explicit cancel uses _cancel_conversation_agent.
# Same conversation is serialized by the lock above. Do not cancel a live conv_lock = await acquire_conversation_run(conv_id) if conv_id else None
# agent for another turn — the orchestrator rejects a second message while
# a run is still active. Explicit cancel uses _cancel_conversation_agent.
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: try:
async for event in _react_agent_stream_impl( agent_task_holder: List["asyncio.Task[Any]"] = []
dialogue, final_emitted = False
tool_mode=tool_mode, done_emitted = False
attachment_ctx=attachment_ctx, from kamidso.engine.app.openapi.api_v1.tools.skill_runtime import (
agent_task_holder=agent_task_holder, bind_shared_execution,
): empty_skill_execution,
event_type = _sse_event_type(event) reset_shared_execution,
final_emitted = final_emitted or event_type == "final" )
done_emitted = done_emitted or event_type == "done" from kamidso.engine.core.reasoning_policy import bind_turn, reset_turn
yield event
except Exception: ext_info_raw = getattr(dialogue, "ext_info", None)
logger.exception("ReAct agent stream failed before normal completion") ext_info = ext_info_raw if isinstance(ext_info_raw, dict) else {}
if not final_emitted and not done_emitted: reasoning_token = bind_turn(ext_info.get("reasoning_mode") or "fast")
yield _sse_event( execution_token = bind_shared_execution(empty_skill_execution())
AgentFinalAnswer( try:
content=_stream_failure_user_message(ext_info) async for event in _react_agent_stream_impl(
).to_sse_payload() dialogue,
) tool_mode=tool_mode,
if not done_emitted: attachment_ctx=attachment_ctx,
yield _sse_event({"type": "done"}) 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: finally:
try: # History persistence runs inside the stream body, before this
reset_turn(reasoning_token) # finally. Scratch memory is dropped while the lock is still held.
except (ValueError, LookupError): await end_react_turn(conv_id, conv_lock)
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)
if conv_lock is not None:
conv_lock.release()
class _AgentStreamingResponse(StreamingResponse): class _AgentStreamingResponse(StreamingResponse):
@@ -2264,6 +2265,20 @@ async def _react_agent_stream_impl(
# Connector selection (Task C): only inject user-selected connectors. # Connector selection (Task C): only inject user-selected connectors.
connector_ids: List[str] = _parse_connector_ids(dialogue.ext_info) 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 code_graph_available = False
if knowledge_space: if knowledge_space:
@@ -2652,6 +2667,10 @@ async def _react_agent_stream_impl(
"database_connector_labels": database_connector_labels, "database_connector_labels": database_connector_labels,
"db_connection_failures": int(db_failure_state.get("db_connection_failures") or 0), "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"): if db_failure_state.get("db_connection_give_up"):
react_state["db_connection_give_up"] = db_failure_state["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.assistant.data_sketch import compose_turn_data_sketch, paths_from_ext_info
@@ -3126,7 +3145,18 @@ print(json.dumps(summary, ensure_ascii=False))
react_state, react_state,
sql_enabled=_this_turn_allows_sql_query(attached_dbs), sql_enabled=_this_turn_allows_sql_query(attached_dbs),
database_runtime=_this_turn_uses_db_runtime(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) shell_interpreter_tool = make_shell_interpreter(react_state)
html_interpreter_tool = make_html_interpreter(react_state, DEFAULT_SKILLS_DIR) html_interpreter_tool = make_html_interpreter(react_state, DEFAULT_SKILLS_DIR)
todowrite_tool = make_todowrite(_todo_list, stream_callback) todowrite_tool = make_todowrite(_todo_list, stream_callback)
@@ -3639,9 +3669,9 @@ print(json.dumps(summary, ensure_ascii=False))
_connector_manager = CFG.SYSTEM_APP.get_component( _connector_manager = CFG.SYSTEM_APP.get_component(
"connector_manager", _ConnectorManager, default_component=None "connector_manager", _ConnectorManager, default_component=None
) )
if _connector_manager is not None and connector_ids: if _connector_manager is not None and mcp_connector_ids:
connector_tool_extras, _missing = _select_connector_tools( connector_tool_extras, _missing = _select_connector_tools(
connector_ids, _connector_manager mcp_connector_ids, _connector_manager
) )
for _mid in _missing: for _mid in _missing:
logger.warning( logger.warning(
@@ -3653,7 +3683,7 @@ print(json.dumps(summary, ensure_ascii=False))
"_react_agent_stream: injected %d connector tool pack(s) " "_react_agent_stream: injected %d connector tool pack(s) "
"(selected: %d)", "(selected: %d)",
len(connector_tool_extras), len(connector_tool_extras),
len(connector_ids), len(mcp_connector_ids),
) )
except Exception: except Exception:
pass # graceful degradation — connector tools are optional pass # graceful degradation — connector tools are optional
@@ -3686,9 +3716,19 @@ print(json.dumps(summary, ensure_ascii=False))
emit_event=_emit_subagent_event, emit_event=_emit_subagent_event,
max_parallel=_max_parallel_subagents, max_parallel=_max_parallel_subagents,
report_mode=report_mode, report_mode=report_mode,
native_connectors=native_connector_instances,
user_name=dialogue.user_name,
) )
_native_prompt = use_native_dbgpt_prompt() _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 is_skill_mode:
if attached_dbs and not has_attached_files: if attached_dbs and not has_attached_files:
skill_compute_rule = ( skill_compute_rule = (
@@ -3842,8 +3882,10 @@ Never use Chinese in any user-visible text.
execute_skill_script, execute_skill_script,
get_skill_resource, get_skill_resource,
execute_skill_script_file_tool, execute_skill_script_file_tool,
code_interpreter_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: if has_attached_files:
skill_mode_tools.append(load_file_tool) skill_mode_tools.append(load_file_tool)
skill_mode_tools.append(shell_interpreter_tool) skill_mode_tools.append(shell_interpreter_tool)
@@ -4044,11 +4086,17 @@ Never use Chinese in any user-visible text.
execute_skill_script, execute_skill_script,
get_skill_resource, get_skill_resource,
execute_skill_script_file_tool, execute_skill_script_file_tool,
code_interpreter_tool,
load_file_tool,
execute_analysis_tool,
shell_interpreter_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: if report_mode:
analysis_tools.append(html_interpreter_tool) analysis_tools.append(html_interpreter_tool)
if expose_sql_query: if expose_sql_query:
@@ -4095,14 +4143,13 @@ Never use Chinese in any user-visible text.
_cm = CFG.SYSTEM_APP.get_component( _cm = CFG.SYSTEM_APP.get_component(
"connector_manager", _ConnectorManager, default_component=None "connector_manager", _ConnectorManager, default_component=None
) )
if _cm is not None and connector_ids: if _cm is not None and mcp_connector_ids:
_active = _cm.list_active() _active = _cm.list_active()
# Only describe connectors the user explicitly selected. # Only describe connectors the user explicitly selected.
# Iterate connector_ids (not _active) so prompt order matches # Iterate mcp ids so native connectors stay out of this block.
# user selection order and stays consistent with _select_connector_tools.
_active_map = {c["connector_id"]: c for c in _active if isinstance(c, dict)} _active_map = {c["connector_id"]: c for c in _active if isinstance(c, dict)}
_selected = [ _selected = [
_active_map[cid] for cid in connector_ids if cid in _active_map _active_map[cid] for cid in mcp_connector_ids if cid in _active_map
] ]
if _selected: if _selected:
_connector_lines = [] _connector_lines = []
@@ -4211,6 +4258,9 @@ Never use Chinese in any user-visible text.
Never invent the meaning of an unresolved field. Never invent the meaning of an unresolved field.
""" """
if native_section:
workflow_prompt += "\n\n" + native_section.replace("{", "{{").replace("}", "}}")
workflow_prompt = ( workflow_prompt = (
f"{workflow_prompt.rstrip()}\n\n" f"{workflow_prompt.rstrip()}\n\n"
f"{_MULTI_TURN_PROMPT_SECTION}\n\n" f"{_MULTI_TURN_PROMPT_SECTION}\n\n"
@@ -4235,7 +4285,11 @@ Never use Chinese in any user-visible text.
.bind(llm_config) .bind(llm_config)
.bind(tool_pack) .bind(tool_pack)
) )
agent_builder = bind_agent_prompt(agent_builder, workflow_prompt_template) agent_builder = bind_agent_prompt(
agent_builder,
workflow_prompt_template,
extra_goal=native_section,
)
agent = await agent_builder.build() agent = await agent_builder.build()
@@ -4348,6 +4402,7 @@ Never use Chinese in any user-visible text.
"agent.start", "agent.start",
"agent.done", "agent.done",
"agent.step", "agent.step",
"agent.thinking",
"subagent.artifacts", "subagent.artifacts",
): ):
# Sub-agent progress uses a separate event channel so parallel # Sub-agent progress uses a separate event channel so parallel
@@ -5216,7 +5271,7 @@ async def chat_react_agent(
headers = { headers = {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive", "X-Accel-Buffering": "no",
"Transfer-Encoding": "chunked", "Transfer-Encoding": "chunked",
} }
try: try:
@@ -5264,7 +5319,7 @@ async def chat_knowledge_agent(
headers = { headers = {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive", "X-Accel-Buffering": "no",
"Transfer-Encoding": "chunked", "Transfer-Encoding": "chunked",
} }
try: try:
+1 -1
View File
@@ -543,7 +543,7 @@ async def chat_completions(
headers = { headers = {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive", "X-Accel-Buffering": "no",
"Transfer-Encoding": "chunked", "Transfer-Encoding": "chunked",
} }
try: try:
@@ -125,7 +125,9 @@ def prepare_react_gpts_memory(cache: Dict[str, GptsMemory], conv_id: str) -> Gpt
Native mode uses persistent MetaDb backends for both messages and plans. Native mode uses persistent MetaDb backends for both messages and plans.
Legacy mode keeps in-process DefaultGptsPlansMemory + MetaDb messages. Legacy mode keeps in-process DefaultGptsPlansMemory + MetaDb messages.
``init`` runs only on cache miss so later user turns keep the scratchpad. ``init`` runs only on cache miss. A finished Kamidso turn drops this entry
with ``dispose_runtime``; the next turn builds a new scratch and reloads
the needed history from MetaDB and ``kamidso_history``.
""" """
existing = cache.get(conv_id) existing = cache.get(conv_id)
if existing is not None: if existing is not None:
@@ -0,0 +1,91 @@
"""In-process lifetime of one ReAct turn.
The dictionaries below used to live for the whole backend process. A finished
turn drops its scratch memory and its conversation lock only after every
waiter for that conversation has left.
"""
from __future__ import annotations
import asyncio
from typing import Dict, Optional
REACT_AGENT_MEMORY_CACHE: Dict[str, object] = {}
# Serialize ReAct runs per conversation. A second message waits on the same
# lock; explicit cancel uses REACT_CONVERSATION_AGENT_TASKS.
REACT_CONVERSATION_LOCKS: Dict[str, asyncio.Lock] = {}
REACT_CONVERSATION_AGENT_TASKS: Dict[str, "asyncio.Task[object]"] = {}
_LOCK_GUARD = asyncio.Lock()
_LOCK_REFS: Dict[str, int] = {}
async def acquire_conversation_run(conv_id: str) -> asyncio.Lock:
"""Take the per-conversation lock and count this waiter.
The refcount is incremented before waiting, so a finishing turn cannot
delete the lock out from under someone already queued on it.
"""
async with _LOCK_GUARD:
lock = REACT_CONVERSATION_LOCKS.get(conv_id)
if lock is None:
lock = asyncio.Lock()
REACT_CONVERSATION_LOCKS[conv_id] = lock
_LOCK_REFS[conv_id] = _LOCK_REFS.get(conv_id, 0) + 1
try:
await lock.acquire()
except BaseException:
# Cancelled while queued. The lock was not taken; drop the reservation
# so a finished turn can still remove it once every other waiter is gone.
await _drop_reservation(conv_id, lock)
raise
return lock
async def release_conversation_run(conv_id: str, lock: asyncio.Lock) -> None:
"""Release one holder. Drop the lock only when nobody else is waiting."""
lock.release()
await _drop_reservation(conv_id, lock)
async def _drop_reservation(conv_id: str, lock: asyncio.Lock) -> None:
"""Forget one reserved waiter. Keep the lock while anyone else still needs it."""
async with _LOCK_GUARD:
refs = _LOCK_REFS.get(conv_id, 1) - 1
current = REACT_CONVERSATION_LOCKS.get(conv_id)
if refs <= 0 and current is lock and not lock.locked():
REACT_CONVERSATION_LOCKS.pop(conv_id, None)
_LOCK_REFS.pop(conv_id, None)
else:
_LOCK_REFS[conv_id] = max(refs, 0)
def release_cached_gpts_memory(conv_id: str) -> None:
"""Drop the turn's GptsMemory scratch. Persisted history is not deleted."""
memory = REACT_AGENT_MEMORY_CACHE.pop(conv_id, None)
if memory is None:
return
dispose = getattr(memory, "dispose_runtime", None)
if callable(dispose):
dispose(conv_id)
async def end_react_turn(conv_id: str, lock: Optional[asyncio.Lock]) -> None:
"""Free scratch memory, then release the conversation lock.
Call this from the stream ``finally`` after the agent task has finished
and after ``_react_terminal_events`` has already persisted the round.
The lock is still held while the cache is removed, so the next waiter
cannot enter until this turn's scratch is gone.
"""
try:
if conv_id:
release_cached_gpts_memory(conv_id)
finally:
if lock is not None and conv_id:
await release_conversation_run(conv_id, lock)
def conversation_lock_refcount(conv_id: str) -> int:
"""Test helper: holders plus waiters that have already reserved the lock."""
return _LOCK_REFS.get(conv_id, 0)
@@ -34,6 +34,7 @@ from kamidso.engine.app.openapi.api_v1.native_dbgpt import (
build_agent_memory, build_agent_memory,
op_snapshot_dir, op_snapshot_dir,
) )
from kamidso.engine.app.openapi.api_v1.thinking_stream import live_thinking_display
from kamidso.engine.core._private.config import Config from kamidso.engine.core._private.config import Config
from kamidso.engine.core.agent.expand.actions.react_action import Terminate from kamidso.engine.core.agent.expand.actions.react_action import Terminate
from kamidso.engine.core.agent.resource import ToolPack, tool from kamidso.engine.core.agent.resource import ToolPack, tool
@@ -211,6 +212,11 @@ Use the database / knowledge base / MCP connector named in your goal.
- You CANNOT delegate further (no parallel sub-tasks of your own). - You CANNOT delegate further (no parallel sub-tasks of your own).
- Keep your context tight; produce a concise, self-contained conclusion. - Keep your context tight; produce a concise, self-contained conclusion.
- When done, call ``terminate`` with your final result. - When done, call ``terminate`` with your final result.
- If ``connector_call`` is in the action space, CRM and other business-app data
MUST be read with ``connector_call`` (semantic operation or the function named
in the goal). Never call ``http://127.0.0.1``, ``http://localhost``, or invent
paths such as ``/api/crm``, ``/api/didar``, or ``/search_deals``. Those hit
this application and return 404; they are not the business API.
## Output formatting (IMPORTANT — keep results readable) ## Output formatting (IMPORTANT — keep results readable)
- One query/result per step. Prefer separate ``database().query`` calls inside - One query/result per step. Prefer separate ``database().query`` calls inside
@@ -324,6 +330,8 @@ async def build_sub_react_agent(
extra_context: Optional[str] = None, extra_context: Optional[str] = None,
batch_id: int = 0, batch_id: int = 0,
report_mode: bool = False, report_mode: bool = False,
native_connectors: Optional[List[Any]] = None,
user_name: Optional[str] = None,
): ):
"""Construct an isolated child ReActAgent for one sub-task. """Construct an isolated child ReActAgent for one sub-task.
@@ -359,6 +367,10 @@ async def build_sub_react_agent(
# (2) Independent react_state — artifact bookkeeping isolation. A fresh # (2) Independent react_state — artifact bookkeeping isolation. A fresh
# dict means generated_images / image_url_map etc. never cross-write. # dict means generated_images / image_url_map etc. never cross-write.
sub_state = {"conv_id": sub_conv_id, "file_path": None} sub_state = {"conv_id": sub_conv_id, "file_path": None}
if native_connectors:
from kamidso.native_connectors.resolve import install_native_connectors
install_native_connectors(sub_state, native_connectors)
# (3) Independent AgentMemory — not cached in REACT_AGENT_MEMORY_CACHE, # (3) Independent AgentMemory — not cached in REACT_AGENT_MEMORY_CACHE,
# so the sub-agent's history never pollutes the parent's memory. # so the sub-agent's history never pollutes the parent's memory.
@@ -377,9 +389,17 @@ async def build_sub_react_agent(
database_connector=database_connector, database_connector=database_connector,
knowledge_resources=knowledge_resources, knowledge_resources=knowledge_resources,
) )
native_tools: List[Any] = []
if sub_state.get("native_connectors"):
from kamidso.engine.app.openapi.api_v1.tools.connector_call import (
make_connector_call,
)
native_tools.append(make_connector_call(sub_state, user_name=user_name))
sub_tool_pack = ToolPack( sub_tool_pack = ToolPack(
[sub_tools[name] for name in _subagent_tool_names(report_mode=report_mode)] [sub_tools[name] for name in _subagent_tool_names(report_mode=report_mode)]
+ (readonly_connector_tools or []) + (readonly_connector_tools or [])
+ native_tools
+ [Terminate()] + [Terminate()]
) )
@@ -500,9 +520,10 @@ an execution mechanism after decomposition, not a decomposition tool.
[Usage rules] [Usage rules]
- Every subtask goal must be self-contained. A sub-agent cannot see the current - Every subtask goal must be self-contained. A sub-agent cannot see the current
conversation history, so put all required information in `goal` or `context`. conversation history, so put all required information in `goal` or `context`.
- Sub-agents inherit database, knowledge-base, and read-only MCP tools, but do - Sub-agents inherit database, knowledge-base, read-only MCP tools, and, when a
not know which resource to use by default. Name the required database and native business connector is attached, ``connector_call``. Name the required
relevant tables, knowledge base, or MCP connector in every goal/context. database, knowledge base, or connector operation in every goal/context.
Do not tell a sub-agent to call localhost or invent ``/api/crm`` paths.
Sub-agents are read-only; the lead agent must perform write operations. Sub-agents are read-only; the lead agent must perform write operations.
- Dispatch at most N subtasks per call. Excess tasks are truncated and reported; - Dispatch at most N subtasks per call. Excess tasks are truncated and reported;
dispatch additional batches when necessary. dispatch additional batches when necessary.
@@ -510,6 +531,50 @@ an execution mechanism after decomposition, not a decomposition tool.
""" """
def subagent_thought_event(
agent_id: str, agent_name: str, raw_text: str
) -> Optional[Dict[str, Any]]:
"""Live thought text for one sub-agent, ready for the chat stream."""
display = live_thinking_display(raw_text)
if not display:
return None
return {
"type": "agent.thinking",
"agent_id": agent_id,
"agent_name": agent_name,
"content": display,
}
def subagent_running_step_event(
agent_id: str,
agent_name: str,
round_num: int,
llm_reply: str,
) -> Optional[Dict[str, Any]]:
"""Announce the tool a sub-agent is about to run, before it returns."""
if not llm_reply:
return None
try:
steps = ReActOutputParser().parse(llm_reply)
except Exception:
return None
if not steps or not steps[0].action:
return None
action = str(steps[0].action)
if action.lower() == "terminate":
return None
return {
"type": "agent.step",
"agent_id": agent_id,
"agent_name": agent_name,
"round": round_num,
"action": action,
"intention": steps[0].action_intention or "",
"status": "running",
}
def make_dispatch_tool( def make_dispatch_tool(
*, *,
parent_conv_id: str, parent_conv_id: str,
@@ -522,6 +587,8 @@ def make_dispatch_tool(
emit_event: Any, emit_event: Any,
max_parallel: int = 3, max_parallel: int = 3,
report_mode: bool = False, report_mode: bool = False,
native_connectors: Optional[List[Any]] = None,
user_name: Optional[str] = None,
): ):
"""Build the ``dispatch_parallel_tasks`` tool bound to the lead context. """Build the ``dispatch_parallel_tasks`` tool bound to the lead context.
@@ -630,26 +697,50 @@ def make_dispatch_tool(
extra_context=extra, extra_context=extra,
batch_id=batch_id, batch_id=batch_id,
report_mode=report_mode, report_mode=report_mode,
native_connectors=native_connectors,
user_name=user_name,
) )
from kamidso.engine.core.agent import AgentMessage from kamidso.engine.core.agent import AgentMessage
# Forward the sub-agent's CONFIRMED tool actions (not raw token # Live thoughts and tool steps go out on this agent_id channel.
# thinking) as agent.step events tagged with this agent_id, so # They never enter the lead loop's round_step_map, which would
# the frontend can show a live "current action" line and a # collide when several sub-agents run at once.
# drill-down step list. These go through emit_event on their own
# channel — they never enter the main loop's round_step_map
# (which keys steps by round_num and would collide across
# parallel sub-agents).
pending_sql_by_round: Dict[int, str] = {} pending_sql_by_round: Dict[int, str] = {}
raw_thoughts: Dict[str, List[str]] = {}
last_thought: Dict[str, str] = {}
async def _sub_stream_callback( async def _sub_stream_callback(
event_type: str, payload: dict, _aid: str = agent_id event_type: str, payload: dict, _aid: str = agent_id
) -> None: ) -> None:
round_num = int(payload.get("round") or 0) round_num = int(payload.get("round") or 0)
if event_type == "thinking_chunk":
piece = payload.get("delta_thinking") or payload.get(
"delta_text"
)
if not piece:
return
parts = raw_thoughts.setdefault(_aid, [])
parts.append(str(piece))
thought = subagent_thought_event(
_aid, title, "".join(parts)
)
if thought and thought["content"] != last_thought.get(_aid):
last_thought[_aid] = thought["content"]
await emit_event(thought)
return
if event_type == "thinking": if event_type == "thinking":
sql = _extract_sql_from_llm_reply(payload.get("llm_reply")) sql = _extract_sql_from_llm_reply(payload.get("llm_reply"))
if sql: if sql:
pending_sql_by_round[round_num] = sql pending_sql_by_round[round_num] = sql
running = subagent_running_step_event(
_aid,
title,
round_num,
str(payload.get("llm_reply") or ""),
)
if running:
running["batch_id"] = batch_id
await emit_event(running)
return return
if event_type != "act": if event_type != "act":
return return
@@ -690,11 +781,13 @@ def make_dispatch_tool(
step_event = { step_event = {
"type": "agent.step", "type": "agent.step",
"agent_id": _aid, "agent_id": _aid,
"agent_name": title,
"batch_id": batch_id, "batch_id": batch_id,
"action": action, "action": action,
"intention": ao.get("action_intention"), "intention": ao.get("action_intention"),
"chunks": chunks, "chunks": chunks,
"round": payload.get("round"), "round": payload.get("round"),
"status": "completed",
} }
if action.lower() == "sql_query": if action.lower() == "sql_query":
sql = _extract_sql_action_input( sql = _extract_sql_action_input(
@@ -216,6 +216,8 @@ def update_subagent_history(
return return
if event_type == "agent.step": if event_type == "agent.step":
if event.get("status") == "running":
return
action = _bounded_text(event.get("action"), _MAX_NAME_CHARS) action = _bounded_text(event.get("action"), _MAX_NAME_CHARS)
if not action: if not action:
return return
@@ -353,6 +353,7 @@ def make_react_tools(
or bool(react_state.get("database_connectors")) or bool(react_state.get("database_connectors"))
or bool(react_state.get("db_runtime")) or bool(react_state.get("db_runtime"))
), ),
native_connector=bool(react_state.get("native_connectors")),
) )
@tool( @tool(
@@ -339,7 +339,11 @@ async def test_dispatch_sql_step_recovers_sql_from_confirmed_thinking_event(
) )
await tool(tasks=[{"goal": "g0", "title": "A"}]) await tool(tasks=[{"goal": "g0", "title": "A"}])
step = next(e for e in events if e["type"] == "agent.step") step = next(
e
for e in events
if e["type"] == "agent.step" and e.get("status") != "running"
)
assert step["sql"] == "SELECT * FROM users" assert step["sql"] == "SELECT * FROM users"
assert "action_input" not in step assert "action_input" not in step
@@ -17,6 +17,8 @@ from kamidso.engine.app.openapi.api_v1.subagent.dispatcher import (
_filter_readonly_connector_tools, _filter_readonly_connector_tools,
_subagent_tool_names, _subagent_tool_names,
build_sub_react_agent, build_sub_react_agent,
subagent_running_step_event,
subagent_thought_event,
) )
@@ -107,6 +109,8 @@ def test_build_sub_prompt_includes_goal():
assert "analyze sales" in pt.template assert "analyze sales" in pt.template
# No context block when extra_context is None. # No context block when extra_context is None.
assert "Shared context" not in pt.template assert "Shared context" not in pt.template
assert "connector_call" in pt.template
assert "localhost" in pt.template
def test_build_sub_prompt_includes_context(): def test_build_sub_prompt_includes_context():
@@ -229,3 +233,72 @@ async def test_build_sub_react_agent_batch_id_isolates_conv_id(monkeypatch):
assert cid_batch1_sub0 == "p__d1_sub_0" assert cid_batch1_sub0 == "p__d1_sub_0"
assert cid_batch2_sub0 == "p__d2_sub_0" assert cid_batch2_sub0 == "p__d2_sub_0"
assert cid_batch1_sub0 != cid_batch2_sub0 assert cid_batch1_sub0 != cid_batch2_sub0
def test_subagent_thought_event_keeps_the_live_thought():
event = subagent_thought_event(
"sub-1",
"صفحه ۲",
"Thought: اتصال دیدار را با connector_call می‌خوانم\nAction: connector_call",
)
assert event is not None
assert event["type"] == "agent.thinking"
assert "connector_call" in event["content"]
assert event["agent_name"] == "صفحه ۲"
def test_subagent_running_step_event_names_the_tool_before_it_returns():
reply = (
"Thought: صفحه بعد معاملات موفق\n"
"Action Intention: دریافت صفحه ۲\n"
"Action: connector_call\n"
'Action Input: {"name": "search_deals"}\n'
)
event = subagent_running_step_event("sub-1", "صفحه ۲", 2, reply)
assert event is not None
assert event["status"] == "running"
assert event["action"] == "connector_call"
assert event["round"] == 2
assert subagent_running_step_event("sub-1", "صفحه ۲", 3, "Action: terminate\nAction Input: {}") is None
@pytest.mark.asyncio
async def test_sub_agent_receives_native_connector_call(monkeypatch):
import kamidso.engine.core.agent.expand.react_agent as react_mod
class _FakeBuilt:
def __init__(self, tool_pack):
self.tool_pack = tool_pack
class _FakeReActAgent:
def __init__(self, *a, **k):
self._tool_pack = None
def bind(self, obj):
from kamidso.engine.core.agent.resource import ToolPack
if isinstance(obj, ToolPack):
self._tool_pack = obj
return self
async def build(self):
return _FakeBuilt(self._tool_pack)
monkeypatch.setattr(react_mod, "ReActAgent", _FakeReActAgent)
agent, _, state = await build_sub_react_agent(
"goal",
0,
parent_conv_id="parent",
llm_client=_make_fake_llm_client(),
native_connectors=[
{
"connector_id": "didar-1",
"connector_type": "didar",
"display_name": "دیدار",
}
],
user_name="user-1",
)
tool_names = set(agent.tool_pack._resources.keys())
assert "connector_call" in tool_names
assert "didar-1" in state["native_connectors"]
@@ -1,6 +1,7 @@
"""Built-in tools for the ReAct agent in agentic_data_api.""" """Built-in tools for the ReAct agent in agentic_data_api."""
from .code_interpreter import make_code_interpreter from .code_interpreter import make_code_interpreter
from .connector_call import make_connector_call
from .execute_analysis import make_execute_analysis from .execute_analysis import make_execute_analysis
from .execute_tool import make_execute_tool from .execute_tool import make_execute_tool
from .html_interpreter import make_html_interpreter from .html_interpreter import make_html_interpreter
@@ -10,7 +11,9 @@ from .load_file import make_load_file
from .load_tools import make_load_tools from .load_tools import make_load_tools
from .question import make_question from .question import make_question
from .read_file import make_read_file from .read_file import make_read_file
from .select_skill import make_select_skill # deprecated; not registered on the live agent from .select_skill import (
make_select_skill, # deprecated; not registered on the live agent
)
from .shell_interpreter import make_shell_interpreter from .shell_interpreter import make_shell_interpreter
from .skill_tools import make_execute_skill_script_file, make_load_skill from .skill_tools import make_execute_skill_script_file, make_load_skill
from .sql_query import make_sql_query from .sql_query import make_sql_query
@@ -32,5 +35,6 @@ __all__ = [
"make_execute_skill_script_file", "make_execute_skill_script_file",
"make_load_skill", "make_load_skill",
"make_sql_query", "make_sql_query",
"make_connector_call",
"make_todowrite", "make_todowrite",
] ]
@@ -119,6 +119,7 @@ def make_code_interpreter(
*, *,
sql_enabled: bool = True, sql_enabled: bool = True,
database_runtime: bool = False, database_runtime: bool = False,
native_connector: bool = False,
): ):
if database_runtime: if database_runtime:
description = ( description = (
@@ -169,6 +170,13 @@ def make_code_interpreter(
"unless you wrote that file yourself. " "unless you wrote that file yourself. "
'Parameters: {"code": "python code string"}' 'Parameters: {"code": "python code string"}'
) )
if native_connector:
description += (
" Business-app data is fetched with connector_call, not with Python HTTP clients. "
"When connector_call saves a result under PLOT_DIR/connector_results/*.json, "
"load that JSON with Python/pandas for calculations, joins, charts or exports. "
"Do not use requests/httpx to call attached business apps."
)
@tool(description=description) @tool(description=description)
async def code_interpreter(code: str) -> str: async def code_interpreter(code: str) -> str:
@@ -0,0 +1,202 @@
"""Single native connector tool. One call, predefined functions only."""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
from kamidso.engine.core.agent.resource.tool.base import tool
from kamidso.native_connectors.errors import (
ConnectorAmbiguous,
ConnectorNotAttached,
NativeConnectorError,
)
from kamidso.native_connectors.executor import NativeConnectorExecutor
from kamidso.native_connectors.redact import redact_text
from kamidso.native_connectors.semantic.catalog import find_operation
from kamidso.native_connectors.semantic.executor import SemanticExecutor
_TOOL_DESCRIPTION = """Call one predefined READ function or semantic operation on a native connector attached to this conversation.
Semantic operations for management questions: sales_summary, deals_created, won_deals, lost_deals, pending_pipeline_snapshot, sales_by_owner, sales_over_time.
For those, pass a business body such as {"period": "مهر ۱۴۰۵"}. Do not build Didar Criteria yourself.
Raw function names must be listed in the attached connector catalog.
Do not invent API paths, HTTP methods, credentials or function names.
If the call returns validation_error, correct the body using the returned schema and retry the SAME function.
Do not switch to another function merely because validation failed.
The tool validates the request before HTTP, injects credentials securely, calls the external service, and saves structured results for later analysis in code_interpreter.
Parameters:
- name: predefined connector function name
- body: JSON body for that function
- connection: attached connector name or id when multiple connectors are attached
"""
_REPEATED_INVALID = (
"This exact invalid request has failed repeatedly. "
"Use the schema from the previous observation and correct the body before retrying."
)
def _chunks(content: str, output_type: str = "text") -> str:
return json.dumps(
{"chunks": [{"output_type": output_type, "content": content}]},
ensure_ascii=False,
)
def _canonical_body(body: Any) -> str:
if body is None:
body = {}
return json.dumps(body, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
def _attached(react_state: Dict[str, Any]) -> List[Dict[str, Any]]:
raw = react_state.get("native_connectors") or {}
if isinstance(raw, dict):
return [item for item in raw.values() if isinstance(item, dict)]
return []
def resolve_connection(
instances: List[Dict[str, Any]],
connection: Optional[str],
) -> Dict[str, Any]:
if not instances:
raise ConnectorNotAttached("No native connector is attached to this conversation.")
needle = (connection or "").strip()
if not needle:
if len(instances) == 1:
return instances[0]
raise ConnectorAmbiguous(
[str(item.get("display_name") or item.get("connector_id")) for item in instances]
)
matches = [
item
for item in instances
if needle == str(item.get("connector_id") or "")
or needle == str(item.get("display_name") or "")
]
if len(matches) == 1:
return matches[0]
if not matches:
raise ConnectorNotAttached(
f"Connector '{needle}' is not attached to this conversation."
)
raise ConnectorAmbiguous(
[str(item.get("display_name") or item.get("connector_id")) for item in matches]
)
def _cache_key(connector_id: str, name: str, body: Any) -> str:
return connector_id + "\n" + name + "\n" + _canonical_body(body)
def _default_executor() -> NativeConnectorExecutor:
system_app = None
try:
from kamidso.engine.app.openapi.api_v1.agentic_data_api import CFG
system_app = getattr(CFG, "SYSTEM_APP", None)
except Exception:
system_app = None
from kamidso.engine.core.agent.resource.connector.credential import CredentialStore
return NativeConnectorExecutor(credential_store=CredentialStore(system_app=system_app))
def make_connector_call(
react_state: Dict[str, Any],
*,
executor: Optional[NativeConnectorExecutor] = None,
user_name: Optional[str] = None,
):
runner = executor or _default_executor()
if user_name:
react_state["user_name"] = user_name
@tool("connector_call", description=_TOOL_DESCRIPTION)
async def connector_call(
name: str,
body: Optional[dict] = None,
connection: Optional[str] = None,
) -> str:
"""Call one predefined read function on an attached native connector."""
try:
instance = resolve_connection(_attached(react_state), connection)
except NativeConnectorError as exc:
return _chunks(json.dumps(exc.to_payload(), ensure_ascii=False))
key = _cache_key(str(instance.get("connector_id") or ""), name, body)
cache = react_state.setdefault("connector_call_cache", {})
if not isinstance(cache, dict):
cache = {}
react_state["connector_call_cache"] = cache
cached = cache.get(key)
if isinstance(cached, dict) and cached.get("observation"):
cached["hits"] = int(cached.get("hits") or 0) + 1
if cached["hits"] >= 2:
saved = cached.get("relative_path") or ""
where = f" Saved result: PLOT_DIR/{saved}." if saved else ""
return _chunks(
"This exact connector request was already executed. "
"Use the existing result or change the request."
+ where
)
relative = cached.get("relative_path") or ""
prefix = "Reuse previous result"
if relative:
prefix += f":\nPLOT_DIR/{relative}"
prefix += "\n\nDo not repeat this exact connector_call.\n\n"
return _chunks(prefix + str(cached.get("observation")))
try:
operation = find_operation(str(instance.get("connector_type") or ""), name)
if operation is not None:
result = await SemanticExecutor(runner).execute(
operation=operation,
connector_id=str(instance["connector_id"]),
body=body,
react_state=react_state,
display_name=str(instance.get("display_name") or ""),
)
else:
result = await runner.execute(
connector_id=str(instance["connector_id"]),
function_name=name,
body=body if body is not None else {},
react_state=react_state,
display_name=str(instance.get("display_name") or ""),
)
except NativeConnectorError as exc:
payload = exc.to_payload()
text = json.dumps(payload, ensure_ascii=False)
if payload.get("http_sent"):
cache[key] = {"observation": text, "relative_path": "", "hits": 0}
return _chunks(redact_text(text))
if not result.get("ok"):
if result.get("error_type") == "validation_error":
failures = react_state.setdefault("connector_failures", {})
if not isinstance(failures, dict):
failures = {}
react_state["connector_failures"] = failures
failures[key] = int(failures.get(key) or 0) + 1
if failures[key] >= 3:
return _chunks(_REPEATED_INVALID)
return _chunks(json.dumps(result, ensure_ascii=False))
observation = redact_text(str(result.get("observation") or ""))
cache[key] = {
"observation": observation,
"relative_path": result.get("relative_path") or "",
"hits": 0,
}
return _chunks(observation, "markdown")
return connector_call
+1 -1
View File
@@ -89,7 +89,7 @@ async def chat_completions(
headers = { headers = {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive", "X-Accel-Buffering": "no",
"Transfer-Encoding": "chunked", "Transfer-Encoding": "chunked",
} }
# check chat request # check chat request
@@ -242,7 +242,13 @@ class ExcelReader:
db_exists = os.path.exists(database_name) db_exists = os.path.exists(database_name)
self.db = duckdb.connect(database=database_name, read_only=False) from kamidso.engine.core.datasource.duckdb_memory import (
open_tool_duckdb,
release_tool_duckdb,
)
self._release_duckdb = release_tool_duckdb
self.db = open_tool_duckdb(database_name, read_only=False)
self.temp_table_name = "temp_table" self.temp_table_name = "temp_table"
self.table_name = table_name self.table_name = table_name
@@ -269,9 +275,20 @@ class ExcelReader:
print(column) print(column)
def close(self): def close(self):
if self.db: db = self.db
self.db.close() self.db = None
self.db = None if not db:
return
release = getattr(self, "_release_duckdb", None)
if callable(release):
try:
release(db)
except Exception:
logger.debug("Failed to release DuckDB memory budget", exc_info=True)
try:
db.close()
except Exception:
logger.debug("Failed to close DuckDB connection", exc_info=True)
def __del__(self): def __del__(self):
self.close() self.close()
@@ -37,11 +37,18 @@ class GptsMemory:
self._message_memory: GptsMessageMemory = ( self._message_memory: GptsMessageMemory = (
message_memory if message_memory is not None else DefaultGptsMessageMemory() message_memory if message_memory is not None else DefaultGptsMessageMemory()
) )
self._executor = executor or ThreadPoolExecutor(max_workers=2) self._owns_executor = executor is None
self._executor = executor or ThreadPoolExecutor(
max_workers=2, thread_name_prefix="gpts-memory"
)
self.messages_cache: defaultdict = defaultdict(list) self.messages_cache: defaultdict = defaultdict(list)
self.channels: defaultdict = defaultdict(Queue) self.channels: defaultdict = defaultdict(Queue)
self.enable_vis_map: defaultdict = defaultdict(bool) self.enable_vis_map: defaultdict = defaultdict(bool)
self.start_round_map: defaultdict = defaultdict(int) self.start_round_map: defaultdict = defaultdict(int)
# DB-GPT chat_messages increments this while it is reading the queue.
# Kamidso's ReAct stream does not, so a vis-disabled turn must not
# enqueue history snapshots that nobody will consume.
self._queue_readers: Dict[str, int] = {}
@property @property
def plans_memory(self) -> GptsPlansMemory: def plans_memory(self) -> GptsPlansMemory:
@@ -75,7 +82,12 @@ class GptsMemory:
return self.channels[conv_id] if conv_id in self.channels else None return self.channels[conv_id] if conv_id in self.channels else None
def clear(self, conv_id: str): def clear(self, conv_id: str):
"""Clear gpt memory.""" """Drop one conversation's in-process queue and scratch lists.
Does not delete MetaDB or any other persisted message history.
DB-GPT still calls this when an application chat finishes. Kamidso
turns use :meth:`dispose_runtime`, which also stops the owned executor.
"""
# clear last message queue # clear last message queue
queue = self.channels.pop(conv_id) # noqa queue = self.channels.pop(conv_id) # noqa
del queue del queue
@@ -91,27 +103,133 @@ class GptsMemory:
# clear start_roun # clear start_roun
start_round = self.start_round_map.pop(conv_id) # noqa start_round = self.start_round_map.pop(conv_id) # noqa
del start_round del start_round
self._queue_readers.pop(conv_id, None)
def attach_queue_reader(self, conv_id: str) -> None:
"""Mark a DB-GPT consumer that is blocked on this conversation queue."""
self._queue_readers[conv_id] = self._queue_readers.get(conv_id, 0) + 1
def detach_queue_reader(self, conv_id: str) -> None:
"""Release a queue consumer registered by :meth:`attach_queue_reader`."""
remaining = self._queue_readers.get(conv_id, 0) - 1
if remaining <= 0:
self._queue_readers.pop(conv_id, None)
else:
self._queue_readers[conv_id] = remaining
def dispose_runtime(self, conv_id: Optional[str] = None) -> None:
"""Free temporary scratch state without touching persisted history.
``message_memory`` and ``plans_memory`` are left intact, including
MetaDB rows. When no conversation scratch remains and this object
created its own executor, that executor is shut down.
"""
if conv_id is None:
conv_ids = (
set(self.channels)
| set(self.messages_cache)
| set(self.enable_vis_map)
| set(self.start_round_map)
| set(self._queue_readers)
)
else:
conv_ids = {conv_id}
for cid in conv_ids:
self._drop_runtime_conversation(cid)
if not self._has_runtime_state():
self.shutdown_executor()
def shutdown_executor(self) -> None:
"""Stop the executor owned by this memory. Shared executors stay up."""
executor = self._executor
if executor is None or not self._owns_executor:
return
self._executor = None
executor.shutdown(wait=False, cancel_futures=True)
def _has_runtime_state(self) -> bool:
return bool(
self.channels
or self.messages_cache
or self.enable_vis_map
or self.start_round_map
or self._queue_readers
)
def _drop_runtime_conversation(self, conv_id: str) -> None:
queue = self.channels.pop(conv_id, None)
if queue is not None:
while True:
try:
queue.get_nowait()
except asyncio.QueueEmpty:
break
except Exception:
break
self.messages_cache.pop(conv_id, None)
self.enable_vis_map.pop(conv_id, None)
self.start_round_map.pop(conv_id, None)
self._queue_readers.pop(conv_id, None)
def _latest_simple_view(self, conv_id: str) -> List[dict]:
"""One view of the newest non-human message, for a live queue reader."""
messages = self.messages_cache.get(conv_id) or []
for message in reversed(messages):
if getattr(message, "sender", None) == "Human":
continue
view_info = getattr(message, "content", None)
action_report_str = getattr(message, "action_report", None)
if action_report_str:
try:
action_out = ActionOutput.from_dict(json.loads(action_report_str))
except Exception:
action_out = None
if action_out is not None and action_out.content:
view_info = action_out.content
return [
{
"sender": getattr(message, "sender", None),
"receiver": getattr(message, "receiver", None),
"model": getattr(message, "model_name", None),
"markdown": view_info,
}
]
return []
async def push_message(self, conv_id: str, temp_msg: Optional[str] = None): async def push_message(self, conv_id: str, temp_msg: Optional[str] = None):
"""Push conversation message.""" """Push conversation message.
queue = self.queue(conv_id)
enable_vis_tag = self.enable_vis_message(conv_id=conv_id)
if enable_vis_tag:
# 如果有临时消息内容需要push 拼接再最末尾,否则直接从短期记忆中发布最后消息
message_view = await self.app_link_chat_message(conv_id)
if temp_msg:
temp_view = await self.agent_stream_message(temp_msg)
message_view = message_view + "\n" + temp_view
await queue.put(message_view)
else: VIS mode still publishes the DB-GPT view the legacy consumer renders.
# 非VIS消息模式,直接推送简单消息列表即可,不做任何处理 With VIS disabled, the full history snapshot is not built. A registered
message_views = await self.simple_message(conv_id) queue reader receives only the latest message (``chunk[-1]``). Kamidso
does not register a reader, so nothing is queued.
"""
enable_vis_tag = self.enable_vis_message(conv_id=conv_id)
if not enable_vis_tag:
if self._queue_readers.get(conv_id, 0) <= 0:
return
message_views = self._latest_simple_view(conv_id)
if temp_msg: if temp_msg:
temp_view = await self.agent_stream_message(temp_msg, False) temp_view = await self.agent_stream_message(temp_msg, False)
if temp_view and len(temp_view) > 0: if temp_view:
message_views.extend(temp_view) message_views.extend(temp_view)
if not message_views:
return
queue = self.queue(conv_id)
if queue is None:
return
await queue.put(message_views) await queue.put(message_views)
return
queue = self.queue(conv_id)
if queue is None:
return
# 如果有临时消息内容需要push 拼接再最末尾,否则直接从短期记忆中发布最后消息
message_view = await self.app_link_chat_message(conv_id)
if temp_msg:
temp_view = await self.agent_stream_message(temp_msg)
message_view = message_view + "\n" + temp_view
await queue.put(message_view)
async def complete(self, conv_id: str): async def complete(self, conv_id: str):
"""Complete conversation message.""" """Complete conversation message."""
@@ -522,14 +640,18 @@ class GptsMemory:
conv_id: str, conv_id: str,
): ):
"""Get chat messages.""" """Get chat messages."""
while True: self.attach_queue_reader(conv_id)
queue = self.queue(conv_id) try:
if not queue: while True:
break queue = self.queue(conv_id)
item = await queue.get() if not queue:
if item == "[DONE]": break
queue.task_done() item = await queue.get()
break if item == "[DONE]":
else: queue.task_done()
yield item break
await asyncio.sleep(0.005) else:
yield item
await asyncio.sleep(0.005)
finally:
self.detach_queue_reader(conv_id)
@@ -14,16 +14,25 @@ _SENSITIVE_KEYS = frozenset({"password", "token", "secret", "api_key", "github_t
class CredentialStore: class CredentialStore:
def __init__(self, system_app: Optional[Any] = None) -> None: def __init__(self, system_app: Optional[Any] = None) -> None:
self._system_app = system_app self._system_app = system_app
self._durable = False
self._master_key = self._resolve_master_key() self._master_key = self._resolve_master_key()
self._fernet = self._build_fernet(self._master_key) self._fernet = self._build_fernet(self._master_key)
def has_durable_key(self) -> bool:
"""True when credentials can be decrypted after a process restart."""
return self._durable
def _resolve_master_key(self) -> bytes: def _resolve_master_key(self) -> bytes:
config_key: Optional[str] = None config_key: Optional[str] = None
if self._system_app is not None and getattr(self._system_app, "config", None): if self._system_app is not None and getattr(self._system_app, "config", None):
config_key = self._system_app.config.get("kamidso.engine.core.app.global.encrypt_key") config_key = self._system_app.config.get("kamidso.engine.core.app.global.encrypt_key")
raw_key = config_key or os.environ.get("ENCRYPT_KEY") raw_key = config_key or os.environ.get("ENCRYPT_KEY")
if isinstance(raw_key, str):
raw_key = raw_key.strip()
if raw_key: if raw_key:
self._durable = True
return raw_key.encode() return raw_key.encode()
self._durable = False
logger.warning( logger.warning(
"CredentialStore falling back to ephemeral master key because no " "CredentialStore falling back to ephemeral master key because no "
"kamidso.engine.core.app.global.encrypt_key or ENCRYPT_KEY was provided. " "kamidso.engine.core.app.global.encrypt_key or ENCRYPT_KEY was provided. "
@@ -0,0 +1,223 @@
"""Cap DuckDB memory for tool connections.
DuckDB's default limit is about 80% of host RAM per database. Each tool
connection is capped, and open tool connections split one aggregate budget
so several analyses cannot each take the full cap at once.
"""
from __future__ import annotations
import logging
import os
import threading
import weakref
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
_DEFAULT_LIMIT = "2GB"
_MEMORY_LIMIT_ENV = "KAMIDSO_DUCKDB_MEMORY_LIMIT"
_AGGREGATE_LIMIT_ENV = "KAMIDSO_DUCKDB_AGGREGATE_MEMORY_LIMIT"
_LOCK = threading.Lock()
_LIVE: Dict[int, weakref.ReferenceType] = {}
_DEFAULT_CAPPED = False
_UNITS = {
"tib": 1024**4,
"gib": 1024**3,
"mib": 1024**2,
"kib": 1024,
"tb": 1000**4,
"gb": 1000**3,
"mb": 1000**2,
"kb": 1000,
"ti": 1024**4,
"gi": 1024**3,
"mi": 1024**2,
"ki": 1024,
"t": 1000**4,
"g": 1000**3,
"m": 1000**2,
"k": 1000,
"b": 1,
}
def configured_memory_limit() -> str:
"""Per-connection cap. ``KAMIDSO_DUCKDB_MEMORY_LIMIT`` overrides the 2GB default."""
raw = (os.environ.get(_MEMORY_LIMIT_ENV) or "").strip()
if not raw:
return _DEFAULT_LIMIT
try:
parse_memory_limit(raw)
except ValueError:
logger.warning("Ignoring invalid %s=%s; using %s", _MEMORY_LIMIT_ENV, raw, _DEFAULT_LIMIT)
return _DEFAULT_LIMIT
return raw
def configured_aggregate_limit() -> str:
"""Budget shared by open tool connections. Defaults to the per-connection cap."""
raw = (os.environ.get(_AGGREGATE_LIMIT_ENV) or "").strip()
if not raw:
return configured_memory_limit()
try:
parse_memory_limit(raw)
except ValueError:
logger.warning(
"Ignoring invalid %s=%s; using the per-connection limit",
_AGGREGATE_LIMIT_ENV,
raw,
)
return configured_memory_limit()
return raw
def parse_memory_limit(value: str) -> int:
"""Parse ``2GB``, ``512MiB``, or a bare byte count."""
text = value.strip().lower().replace(" ", "")
if not text:
raise ValueError("empty memory limit")
if text.isdigit():
return int(text)
for unit in sorted(_UNITS, key=len, reverse=True):
if text.endswith(unit):
number = text[: -len(unit)]
if not number:
raise ValueError(value)
return int(float(number) * _UNITS[unit])
raise ValueError(value)
def format_duckdb_limit(num_bytes: int) -> str:
"""Render a byte count with a unit DuckDB's parser accepts."""
if num_bytes <= 0:
return "1MiB"
if num_bytes % (1024**3) == 0:
return f"{num_bytes // (1024**3)}GiB"
mebibytes = max(1, num_bytes // (1024**2))
return f"{mebibytes}MiB"
def cap_duckdb_default_connection() -> None:
"""Stop the module-level DuckDB connection from taking ~80% of host RAM.
That connection is not part of the tool aggregate. It only needs a ceiling.
"""
global _DEFAULT_CAPPED
if _DEFAULT_CAPPED:
return
_DEFAULT_CAPPED = True
try:
import duckdb
except Exception:
logger.debug("duckdb is not installed; skip default memory cap", exc_info=True)
return
try:
connection = duckdb.default_connection()
connection.execute(f"SET memory_limit='{configured_memory_limit()}'")
except Exception:
logger.warning("Failed to cap the default DuckDB connection", exc_info=True)
def register_tool_duckdb(connection: object) -> None:
"""Apply the shared budget to a tool connection and remember it."""
cap_duckdb_default_connection()
key = id(connection)
def _dead(ref: weakref.ReferenceType, conn_id: int = key) -> None:
with _LOCK:
current = _LIVE.get(conn_id)
if current is ref:
_LIVE.pop(conn_id, None)
_rebalance_locked()
with _LOCK:
_LIVE[key] = weakref.ref(connection, _dead)
_rebalance_locked()
def release_tool_duckdb(connection: object) -> None:
"""Forget a tool connection and give its budget back to the ones still open."""
key = id(connection)
with _LOCK:
_LIVE.pop(key, None)
_rebalance_locked()
def bind_duckdb_memory_limit(engine: object) -> None:
"""Set the cap on every DBAPI connection this SQLAlchemy engine opens."""
if getattr(engine, "_kamidso_duckdb_memory_bound", False):
return
setattr(engine, "_kamidso_duckdb_memory_bound", True)
cap_duckdb_default_connection()
from sqlalchemy import event
@event.listens_for(engine, "connect")
def _on_connect(dbapi_connection, _connection_record) -> None: # noqa: ANN001
register_tool_duckdb(dbapi_connection)
@event.listens_for(engine, "close")
def _on_close(dbapi_connection, _connection_record) -> None: # noqa: ANN001
release_tool_duckdb(dbapi_connection)
def open_tool_duckdb(database: str = ":memory:", read_only: bool = False):
"""Open a DuckDB database for a tool and register it under the memory budget."""
import duckdb
connection = duckdb.connect(database=database, read_only=read_only)
register_tool_duckdb(connection)
return connection
def reset_duckdb_memory_state_for_tests() -> None:
"""Drop registry entries. Does not close caller-owned connections."""
global _DEFAULT_CAPPED
with _LOCK:
_LIVE.clear()
_DEFAULT_CAPPED = False
def _share_bytes(open_connections: int) -> int:
count = max(1, open_connections)
per_connection = parse_memory_limit(configured_memory_limit())
aggregate = parse_memory_limit(configured_aggregate_limit())
return max(1024 * 1024, min(per_connection, aggregate // count))
def _limit_sql(open_connections: int) -> str:
count = max(1, open_connections)
per_text = configured_memory_limit()
per_bytes = parse_memory_limit(per_text)
share = _share_bytes(count)
if count == 1 and share == per_bytes:
return per_text
return format_duckdb_limit(share)
def _rebalance_locked() -> None:
live = _live_connections_locked()
if not live:
return
statement = f"SET memory_limit='{_limit_sql(len(live))}'"
for connection in live:
try:
connection.execute(statement)
except Exception:
logger.debug("Could not update DuckDB memory_limit", exc_info=True)
def _live_connections_locked() -> List[object]:
dead: List[int] = []
live: List[object] = []
for key, ref in _LIVE.items():
connection: Optional[object] = ref()
if connection is None:
dead.append(key)
else:
live.append(connection)
for key in dead:
_LIVE.pop(key, None)
return live
+81 -41
View File
@@ -2,6 +2,7 @@ import asyncio
import json import json
import logging import logging
import os import os
from contextlib import asynccontextmanager
from concurrent.futures import Executor from concurrent.futures import Executor
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Type, Union from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, List, Optional, Type, Union
@@ -347,6 +348,9 @@ class OpenAILLMClient(ProxyLLMClient):
self._stream_idle_timeout = stream_idle_timeout_seconds() self._stream_idle_timeout = stream_idle_timeout_seconds()
self._api_type = api_type self._api_type = api_type
self._client = openai_client self._client = openai_client
# Tests inject one client. Production streams each open their own so a
# proxy reset on one chat cannot close the other chat's socket.
self._client_is_injected = openai_client is not None
self._openai_kwargs = openai_kwargs or {} self._openai_kwargs = openai_kwargs or {}
resolved_reasoning = coerce_optional_bool(reasoning_enabled) resolved_reasoning = coerce_optional_bool(reasoning_enabled)
if resolved_reasoning is None: if resolved_reasoning is None:
@@ -416,6 +420,33 @@ class OpenAILLMClient(ProxyLLMClient):
) )
return self._client return self._client
@asynccontextmanager
async def _open_stream_client(self):
"""HTTP client that lives only for this stream.
A shared client keeps one proxy tunnel. The next user's stream is
written onto that tunnel and both responses reset.
"""
if self._client_is_injected:
yield self._client
return
from kamidso.engine.core.model.utils.chatgpt_utils import _build_openai_client
_api_type, client = _build_openai_client(
init_params=self._init_params,
timeout=self._stream_idle_timeout,
isolated=True,
)
try:
yield client
finally:
close = getattr(client, "close", None)
if close is not None:
try:
await close()
except Exception:
logger.debug("Failed to close stream LLM client", exc_info=True)
@property @property
def default_model(self) -> str: def default_model(self) -> str:
model = self._model model = self._model
@@ -540,9 +571,15 @@ class OpenAILLMClient(ProxyLLMClient):
async for r in self.generate_stream_v1(messages, payload): async for r in self.generate_stream_v1(messages, payload):
yield r yield r
async def _create_chat(self, messages: List[Dict[str, Any]], payload: Dict[str, Any]): async def _create_chat(
self,
messages: List[Dict[str, Any]],
payload: Dict[str, Any],
client: Optional["ClientType"] = None,
):
openai_client = client if client is not None else self.client
return await asyncio.wait_for( return await asyncio.wait_for(
self.client.chat.completions.create(messages=messages, **payload), openai_client.chat.completions.create(messages=messages, **payload),
timeout=self._stream_idle_timeout, timeout=self._stream_idle_timeout,
) )
@@ -600,49 +637,52 @@ class OpenAILLMClient(ProxyLLMClient):
for attempt in range(1, attempts + 1): for attempt in range(1, attempts + 1):
yielded = False yielded = False
try: try:
chat_completion = await self._create_chat(messages, payload) async with self._open_stream_client() as client:
text = "" chat_completion = await self._create_chat(
reasoning_content = "" messages, payload, client=client
usage = None )
tool_calls_acc: Optional[List[Dict[str, Any]]] = None text = ""
async for r in _iter_with_idle_timeout( reasoning_content = ""
chat_completion, self._stream_idle_timeout usage = None
): tool_calls_acc: Optional[List[Dict[str, Any]]] = None
if len(r.choices) == 0: async for r in _iter_with_idle_timeout(
continue chat_completion, self._stream_idle_timeout
# Check for empty 'choices' issue in Azure GPT-4o responses ):
if r.choices[0] is not None and r.choices[0].delta is None: if len(r.choices) == 0:
continue continue
delta_obj = r.choices[0].delta # Check for empty 'choices' issue in Azure GPT-4o responses
if hasattr(delta_obj, "reasoning_content"): if r.choices[0] is not None and r.choices[0].delta is None:
reasoning_content += delta_obj.reasoning_content or "" continue
if r.choices[0].delta.content is not None: delta_obj = r.choices[0].delta
text += r.choices[0].delta.content if hasattr(delta_obj, "reasoning_content"):
delta_tool_calls = getattr(delta_obj, "tool_calls", None) or None reasoning_content += delta_obj.reasoning_content or ""
if delta_tool_calls: if r.choices[0].delta.content is not None:
tool_calls_acc = merge_streaming_tool_calls( text += r.choices[0].delta.content
tool_calls_acc, delta_tool_calls delta_tool_calls = getattr(delta_obj, "tool_calls", None) or None
) if delta_tool_calls:
if text or reasoning_content: tool_calls_acc = merge_streaming_tool_calls(
if hasattr(r, "usage") and r.usage is not None: tool_calls_acc, delta_tool_calls
usage = r.usage.dict() )
if text or reasoning_content:
if hasattr(r, "usage") and r.usage is not None:
usage = r.usage.dict()
yielded = True
yield self._drop_thinking_if_disabled(
ModelOutput.build(text, reasoning_content, usage=usage),
payload,
)
if tool_calls_acc:
yielded = True yielded = True
yield self._drop_thinking_if_disabled( yield self._drop_thinking_if_disabled(
ModelOutput.build(text, reasoning_content, usage=usage), ModelOutput.build(
text,
reasoning_content,
usage=usage,
tool_calls=tool_calls_acc,
),
payload, payload,
) )
if tool_calls_acc: return
yielded = True
yield self._drop_thinking_if_disabled(
ModelOutput.build(
text,
reasoning_content,
usage=usage,
tool_calls=tool_calls_acc,
),
payload,
)
return
except Exception as exc: except Exception as exc:
if not _is_stream_stall(exc) or yielded or attempt >= attempts: if not _is_stream_stall(exc) or yielded or attempt >= attempts:
if _is_stream_stall(exc): if _is_stream_stall(exc):
@@ -96,3 +96,50 @@ async def test_stalled_stream_retries_the_same_request(monkeypatch):
assert texts == ["بازیابی شد"] assert texts == ["بازیابی شد"]
assert completions_client.completions.calls == 2 assert completions_client.completions.calls == 2
@pytest.mark.asyncio
async def test_concurrent_streams_open_separate_http_clients(monkeypatch):
created = []
class _OneShot:
default_headers = {}
def __init__(self, isolated: bool):
self.isolated = isolated
self.closed = False
self.chat = SimpleNamespace(completions=_Completions([_TokenStream("ok")]))
async def close(self):
self.closed = True
def _fake_build(init_params, timeout=None, isolated=False):
client = _OneShot(isolated)
created.append(client)
return "open_ai", client
monkeypatch.setattr(
"kamidso.engine.core.model.utils.chatgpt_utils._build_openai_client",
_fake_build,
)
owner = OpenAILLMClient(api_key="test-key", model="qwen")
async def collect():
texts = []
async for output in owner.generate_stream_v1(
[], {"model": "qwen", "stream": True}
):
texts.append(output.text)
return texts
first, second = await asyncio.gather(collect(), collect())
isolated = [client for client in created if client.isolated]
shared = [client for client in created if not client.isolated]
assert first == ["ok"]
assert second == ["ok"]
assert len(shared) == 1
assert len(isolated) == 2
assert isolated[0] is not isolated[1]
assert all(client.closed for client in isolated)
assert shared[0].closed is False
@@ -113,6 +113,8 @@ def _initialize_openai_v1(init_params: OpenAIParameters):
def _build_openai_client( def _build_openai_client(
init_params: OpenAIParameters, init_params: OpenAIParameters,
timeout: Optional[float] = None, timeout: Optional[float] = None,
*,
isolated: bool = False,
) -> Tuple[str, ClientType]: ) -> Tuple[str, ClientType]:
import httpx import httpx
@@ -146,13 +148,22 @@ def _build_openai_client(
# Remove proxies for httpx AsyncClient when httpx version >= 0.28.0 # Remove proxies for httpx AsyncClient when httpx version >= 0.28.0
httpx_version = metadata.version("httpx") httpx_version = metadata.version("httpx")
limits = openai_httpx_limits() # A stream-scoped client must not park its socket in a shared pool.
client_kwargs = {"limits": limits} # Proxies reuse that socket for the next user and both streams reset.
limits = (
httpx.Limits(max_connections=4, max_keepalive_connections=0)
if isolated
else openai_httpx_limits()
)
client_kwargs = {"limits": limits, "http2": False}
if http_timeout is not None: if http_timeout is not None:
client_kwargs["timeout"] = http_timeout client_kwargs["timeout"] = http_timeout
proxy = init_params.proxy
if isinstance(proxy, str):
proxy = proxy.strip() or None
if httpx_version >= "0.28.0": if httpx_version >= "0.28.0":
if init_params.proxy: if proxy:
client_kwargs["proxy"] = init_params.proxy client_kwargs["proxy"] = proxy
http_client = httpx.AsyncClient(**client_kwargs) http_client = httpx.AsyncClient(**client_kwargs)
elif init_params.proxies: elif init_params.proxies:
client_kwargs["proxies"] = init_params.proxies client_kwargs["proxies"] = init_params.proxies
@@ -1,6 +1,12 @@
"""httpx connection limits for the OpenAI-compatible proxy client.""" """httpx connection limits for the OpenAI-compatible proxy client."""
from kamidso.engine.core.model.utils.chatgpt_utils import openai_httpx_limits import pytest
from kamidso.engine.core.model.utils.chatgpt_utils import (
OpenAIParameters,
_build_openai_client,
openai_httpx_limits,
)
def test_openai_httpx_limits_match_proxy_concurrency(): def test_openai_httpx_limits_match_proxy_concurrency():
@@ -8,3 +14,18 @@ def test_openai_httpx_limits_match_proxy_concurrency():
assert limits.max_connections == 100 assert limits.max_connections == 100
assert limits.max_keepalive_connections == 40 assert limits.max_keepalive_connections == 40
@pytest.mark.asyncio
async def test_isolated_stream_client_does_not_reuse_connections():
_api_type, client = _build_openai_client(
OpenAIParameters(api_key="test-key", api_base="https://example.invalid/v1"),
timeout=30,
isolated=True,
)
try:
pool = client._client._transport._pool
assert pool._max_keepalive_connections == 0
assert pool._max_connections == 4
finally:
await client.close()
@@ -49,6 +49,12 @@ class DuckDbConnector(RDBMSConnector):
db_type: str = "duckdb" db_type: str = "duckdb"
db_dialect: str = "duckdb" db_dialect: str = "duckdb"
def __init__(self, engine, **kwargs: Any):
from kamidso.engine.core.datasource.duckdb_memory import bind_duckdb_memory_limit
bind_duckdb_memory_limit(engine)
super().__init__(engine, **kwargs)
@classmethod @classmethod
def param_class(cls) -> Type[DuckDbConnectorParameters]: def param_class(cls) -> Type[DuckDbConnectorParameters]:
"""Return the parameter class.""" """Return the parameter class."""
+23 -15
View File
@@ -319,6 +319,9 @@ class MultiAgents(BaseComponent, ABC):
# init agent memory # init agent memory
agent_memory = self.get_or_build_agent_memory(conv_id, gpts_name) agent_memory = self.get_or_build_agent_memory(conv_id, gpts_name)
# Register before the agent task starts so early messages still have
# a consumer when VIS is disabled and the simple stream reads chunk[-1].
self.memory.attach_queue_reader(agent_conv_id)
task = None task = None
try: try:
task = asyncio.create_task( task = asyncio.create_task(
@@ -420,6 +423,7 @@ class MultiAgents(BaseComponent, ABC):
yield task, str(e), agent_conv_id yield task, str(e), agent_conv_id
finally: finally:
self.memory.detach_queue_reader(agent_conv_id)
self.memory.clear(agent_conv_id) self.memory.clear(agent_conv_id)
def is_flow_chat(self, gpts_name: str): def is_flow_chat(self, gpts_name: str):
@@ -735,21 +739,25 @@ class MultiAgents(BaseComponent, ABC):
user_code: str = None, user_code: str = None,
system_app: str = None, system_app: str = None,
): ):
while True: self.memory.attach_queue_reader(conv_id)
queue = self.memory.queue(conv_id) try:
if not queue: while True:
break queue = self.memory.queue(conv_id)
item = await queue.get() if not queue:
if item == "[DONE]": break
queue.task_done() item = await queue.get()
break if item == "[DONE]":
elif isinstance(item, str) and item.startswith("__CONTEXT_STATUS__"): queue.task_done()
# Forward context status as a dedicated SSE event break
yield item elif isinstance(item, str) and item.startswith("__CONTEXT_STATUS__"):
await asyncio.sleep(0.005) # Forward context status as a dedicated SSE event
else: yield item
yield item await asyncio.sleep(0.005)
await asyncio.sleep(0.005) else:
yield item
await asyncio.sleep(0.005)
finally:
self.memory.detach_queue_reader(conv_id)
async def stable_message( async def stable_message(
self, conv_id: str, user_code: str = None, system_app: str = None self, conv_id: str, user_code: str = None, system_app: str = None
@@ -124,6 +124,33 @@ async def list_connector_types() -> Result[List[Dict[str, Any]]]:
) )
) )
options.append(_CUSTOM_MCP_OPTION) options.append(_CUSTOM_MCP_OPTION)
from kamidso.native_connectors.flags import (
didar_connector_enabled,
native_connectors_v1_enabled,
)
from kamidso.native_connectors.registry import get_registry
if native_connectors_v1_enabled() and didar_connector_enabled():
registry = get_registry()
if registry.contains("didar"):
provider = registry.get("didar")
options.append(
ConnectorTypeOption(
type=provider.id,
display_name=provider.display_name,
description="Read-only Didar CRM connector",
category=provider.category or "crm",
is_custom=False,
auth_fields=[
{
"name": provider.credential_field,
"label": "API Key",
"type": "password",
"required": True,
}
],
)
)
return Result.succ([o.model_dump() for o in options]) return Result.succ([o.model_dump() for o in options])
@@ -265,6 +292,22 @@ async def test_connector(
return Result.failed(msg=str(e)) return Result.failed(msg=str(e))
@router.get("/{connector_id}/functions")
async def get_connector_functions(
connector_id: str,
service: ConnectorService = Depends(get_service),
) -> Result[Dict[str, Any]]:
try:
return Result.succ(service.list_native_functions(connector_id))
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except Exception as exc:
logger.exception("Get connector functions failed")
return Result.failed(msg=str(exc))
@router.get("/{connector_id}/tools", response_model=Result[ConnectorToolsResponse]) @router.get("/{connector_id}/tools", response_model=Result[ConnectorToolsResponse])
async def get_connector_tools( async def get_connector_tools(
connector_id: str, connector_id: str,
@@ -4,6 +4,7 @@ import asyncio
import json import json
import logging import logging
import uuid import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Literal, Optional from typing import Any, Dict, List, Literal, Optional
from fastapi import HTTPException from fastapi import HTTPException
@@ -24,6 +25,25 @@ from ..models.models import ConnectorInstanceDao, ConnectorInstanceEntity
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _utc_stamp() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
class ConnectorProbeFailed(ValueError):
"""Health check failed before a native connector credential was saved."""
def __init__(
self,
message: str,
*,
status_code: Optional[int] = None,
error_type: Optional[str] = None,
) -> None:
super().__init__(message)
self.status_code = status_code
self.error_type = error_type
class ConnectorCreateRequest(BaseModel): class ConnectorCreateRequest(BaseModel):
"""Request model for creating a connector.""" """Request model for creating a connector."""
@@ -158,6 +178,109 @@ class ConnectorService(
"""Initialize the service.""" """Initialize the service."""
self._system_app = system_app self._system_app = system_app
self._credential_store = CredentialStore(system_app=system_app) self._credential_store = CredentialStore(system_app=system_app)
from kamidso.native_connectors.flags import native_connectors_v1_enabled
from kamidso.native_connectors.registry import get_registry
if native_connectors_v1_enabled():
get_registry()
def _is_native_type(self, connector_type: Optional[str]) -> bool:
from kamidso.native_connectors.flags import native_connectors_v1_enabled
from kamidso.native_connectors.registry import get_registry
if not native_connectors_v1_enabled() or not connector_type:
return False
return get_registry().contains(connector_type)
def _require_durable_native_key(self) -> None:
from kamidso.native_connectors.flags import is_production
if is_production() and not self._credential_store.has_durable_key():
raise ValueError(
"ENCRYPT_KEY must be configured before creating a native connector "
"in production."
)
def _native_executor(self):
from kamidso.native_connectors.executor import NativeConnectorExecutor
return NativeConnectorExecutor(credential_store=self._credential_store)
def _create_native_connector(self, request: ConnectorCreateRequest) -> ConnectorResponse:
"""Persist a native connector only after its catalog health check succeeds."""
self._require_durable_native_key()
from kamidso.native_connectors.registry import get_registry
provider = get_registry().get(request.connector_type)
credentials = {k: str(v) for k, v in request.credentials.items()}
if not str(credentials.get(provider.credential_field) or "").strip():
raise ValueError(
f"Missing credential field: {provider.credential_field}"
)
loop = get_or_create_event_loop()
probed = loop.run_until_complete(
self._native_executor().probe(request.connector_type, credentials)
)
if not probed.get("success"):
status_code = probed.get("status_code")
raise ConnectorProbeFailed(
str(probed.get("message") or "Connection test failed"),
status_code=status_code if isinstance(status_code, int) else None,
error_type=str(probed.get("error_type") or "") or None,
)
connector_id = str(uuid.uuid4())
encryption_salt = self._credential_store.generate_salt()
encrypted_credentials = self._credential_store.encrypt(
credentials,
encryption_salt,
)
config = dict(request.config or {})
config["last_tested_at"] = _utc_stamp()
config["last_test_success"] = True
config_json = json.dumps(config)
entity = ConnectorInstanceEntity(
connector_id=connector_id,
connector_type=request.connector_type,
display_name=request.display_name,
encrypted_credentials=encrypted_credentials,
encryption_salt=encryption_salt,
status="active",
config_json=config_json,
user_name=request.user_name,
sys_code=request.sys_code,
)
with self._dao.session() as session:
session.add(entity)
session.flush()
session.refresh(entity)
return _entity_to_response(entity)
def list_native_functions(self, connector_id: str) -> Dict[str, Any]:
"""Return the predefined function catalog summary for one native connector."""
connector = self.get_connector(connector_id)
if connector is None:
raise HTTPException(
status_code=404, detail=f"Connector '{connector_id}' not found"
)
if not self._is_native_type(connector.connector_type):
raise ValueError(f"Connector '{connector_id}' is not a native connector")
from kamidso.native_connectors.registry import get_registry
provider = get_registry().get(connector.connector_type)
return {
"provider": provider.id,
"functions": [
{
"name": function.name,
"description": function.description,
"domain": function.domain,
"effect": function.effect,
"enabled": function.enabled,
}
for function in provider.functions
],
}
def after_start(self): def after_start(self):
"""Rehydrate active connectors into the runtime connector manager.""" """Rehydrate active connectors into the runtime connector manager."""
@@ -199,6 +322,8 @@ class ConnectorService(
) )
for connector in active_connectors: for connector in active_connectors:
if self._is_native_type(connector.get("connector_type")):
continue
try: try:
credentials = self._credential_store.decrypt( credentials = self._credential_store.decrypt(
connector["encrypted_credentials"], connector["encrypted_credentials"],
@@ -280,6 +405,9 @@ class ConnectorService(
Returns: Returns:
ConnectorResponse: The created connector response. ConnectorResponse: The created connector response.
""" """
if self._is_native_type(request.connector_type):
return self._create_native_connector(request)
connector_id = str(uuid.uuid4()) connector_id = str(uuid.uuid4())
encryption_salt = self._credential_store.generate_salt() encryption_salt = self._credential_store.generate_salt()
encrypted_credentials = self._credential_store.encrypt( encrypted_credentials = self._credential_store.encrypt(
@@ -412,12 +540,23 @@ class ConnectorService(
connector_id, connector_id,
) )
existing = {} existing = {}
# New fields override existing; existing fields survive when
# new value is empty/missing.
merged = { merged = {
**existing, **existing,
**{k: str(v) for k, v in request.credentials.items()}, **{k: str(v) for k, v in request.credentials.items()},
} }
if self._is_native_type(entity.connector_type):
self._require_durable_native_key()
loop = get_or_create_event_loop()
probed = loop.run_until_complete(
self._native_executor().probe(entity.connector_type, merged)
)
if not probed.get("success"):
status_code = probed.get("status_code")
raise ConnectorProbeFailed(
str(probed.get("message") or "Connection test failed"),
status_code=status_code if isinstance(status_code, int) else None,
error_type=str(probed.get("error_type") or "") or None,
)
entity.encryption_salt = self._credential_store.generate_salt() entity.encryption_salt = self._credential_store.generate_salt()
entity.encrypted_credentials = self._credential_store.encrypt( entity.encrypted_credentials = self._credential_store.encrypt(
merged, merged,
@@ -430,9 +569,10 @@ class ConnectorService(
entity.config_json = json.dumps(request.config) entity.config_json = json.dumps(request.config)
config_changed = True config_changed = True
# P0-1 fix: re-activate manager if credentials/config changed, if self._is_native_type(entity.connector_type):
# so the connector becomes usable AND the status reflects reality. if credentials_changed or config_changed:
if credentials_changed or config_changed: entity.status = "active"
elif credentials_changed or config_changed:
previous_status = entity.status previous_status = entity.status
entity.status = "active" entity.status = "active"
@@ -510,6 +650,73 @@ class ConnectorService(
raise ValueError(f"Connector '{connector_id}' not found") raise ValueError(f"Connector '{connector_id}' not found")
session.delete(entity) session.delete(entity)
def retire_connector(self, connector_id: str) -> None:
"""Hide a connector from new chats without dropping its history row."""
with self._dao.session() as session:
entity = (
session.query(ConnectorInstanceEntity)
.filter(ConnectorInstanceEntity.connector_id == connector_id)
.first()
)
if entity is None:
raise ValueError(f"Connector '{connector_id}' not found")
entity.status = "deleted"
connector_manager = self.system_app.get_component(
"connector_manager",
ConnectorManager,
default_component=None,
)
if connector_manager is None:
return
loop = get_or_create_event_loop()
try:
loop.run_until_complete(connector_manager.remove_connector(connector_id))
except Exception:
logger.info("Connector '%s' was not active in the MCP manager", connector_id)
def record_last_test(self, connector_id: str, *, success: bool) -> None:
"""Remember the latest health check without storing credentials."""
try:
with self._dao.session() as session:
entity = (
session.query(ConnectorInstanceEntity)
.filter(ConnectorInstanceEntity.connector_id == connector_id)
.first()
)
if entity is None:
return
config: Dict[str, Any] = {}
if entity.config_json:
try:
loaded = json.loads(entity.config_json)
if isinstance(loaded, dict):
config = loaded
except Exception:
config = {}
config["last_tested_at"] = _utc_stamp()
config["last_test_success"] = success
entity.config_json = json.dumps(config)
except Exception:
logger.exception("Failed to record the last test for '%s'", connector_id)
async def test_native_credentials(
self, connector_type: str, credentials: Dict[str, Any]
) -> Dict[str, Any]:
"""Probe a native provider without writing a connector row."""
if not self._is_native_type(connector_type):
raise ValueError("Unknown native provider")
from kamidso.native_connectors.registry import get_registry
provider = get_registry().get(connector_type)
cleaned = {
str(key): str(value)
for key, value in credentials.items()
if str(value).strip()
}
if not str(cleaned.get(provider.credential_field) or "").strip():
raise ValueError(f"Missing credential field: {provider.credential_field}")
return await self._native_executor().probe(connector_type, cleaned)
def list_tools(self, connector_id: str) -> ConnectorToolsResponse: def list_tools(self, connector_id: str) -> ConnectorToolsResponse:
"""Return the tool summaries for a single connector. """Return the tool summaries for a single connector.
@@ -528,6 +735,13 @@ class ConnectorService(
raise HTTPException( raise HTTPException(
status_code=404, detail=f"Connector '{connector_id}' not found" status_code=404, detail=f"Connector '{connector_id}' not found"
) )
current = self.get_connector(connector_id)
if current is not None and self._is_native_type(current.connector_type):
return ConnectorToolsResponse(
connector_id=connector_id,
state="not_mcp",
tools=[],
)
connector_manager = self.system_app.get_component( connector_manager = self.system_app.get_component(
"connector_manager", "connector_manager",
@@ -625,6 +839,22 @@ class ConnectorService(
if connector is None: if connector is None:
raise ValueError(f"Connector '{connector_id}' not found") raise ValueError(f"Connector '{connector_id}' not found")
if self._is_native_type(connector.connector_type):
probed = await self._native_executor().probe_stored(connector_id)
success = bool(probed.get("success"))
if success:
self._heal_status_to_active(connector_id)
self.record_last_test(connector_id, success=success)
result: Dict[str, Any] = {
"success": success,
"message": str(probed.get("message") or ""),
}
if isinstance(probed.get("status_code"), int):
result["status_code"] = probed["status_code"]
if probed.get("error_type"):
result["error_type"] = probed["error_type"]
return result
cm = self.system_app.get_component( cm = self.system_app.get_component(
"connector_manager", ConnectorManager, default_component=None "connector_manager", ConnectorManager, default_component=None
) )
@@ -1163,3 +1163,160 @@ def test_list_tools_p95_latency_under_100ms_for_50_tools(
samples_ms.sort() samples_ms.sort()
p95 = samples_ms[int(len(samples_ms) * 0.95) - 1] p95 = samples_ms[int(len(samples_ms) * 0.95) - 1]
assert p95 < 100.0, f"p95={p95:.2f}ms exceeds 100ms budget" assert p95 < 100.0, f"p95={p95:.2f}ms exceeds 100ms budget"
def _enable_didar(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("KAMIDSO_NATIVE_CONNECTORS_V1", "1")
monkeypatch.setenv("KAMIDSO_DIDAR_CONNECTOR", "1")
from kamidso.native_connectors.registry import reset_registry
reset_registry()
def test_native_create_persists_without_mcp_and_encrypts_api_key(
service: ConnectorService, monkeypatch: pytest.MonkeyPatch
):
_enable_didar(monkeypatch)
from kamidso.native_connectors.executor import NativeConnectorExecutor
async def _probe(self, provider_id, credentials):
assert credentials["api_key"] == "didar-secret"
return {"success": True, "message": "ok"}
monkeypatch.setattr(NativeConnectorExecutor, "probe", _probe)
fake_manager = MagicMock()
fake_manager.create_connector = AsyncMock()
def _fake_get_component(name, component_type, default_component=None):
if name == "connector_manager":
return fake_manager
return default_component
monkeypatch.setattr(service.system_app, "get_component", _fake_get_component)
response = service.create_connector(
ConnectorCreateRequest(
connector_type="didar",
display_name="دیدار فروش",
credentials={"api_key": "didar-secret"},
user_name="usr_test",
)
)
fake_manager.create_connector.assert_not_called()
assert response.status == "active"
assert "didar-secret" not in response.model_dump_json()
with db.session() as session:
entity = (
session.query(ConnectorInstanceEntity)
.filter(ConnectorInstanceEntity.connector_id == response.connector_id)
.first()
)
assert entity is not None
assert entity.connector_type == "didar"
assert "didar-secret" not in (entity.encrypted_credentials or "")
store = CredentialStore(system_app=service.system_app)
assert store.decrypt(entity.encrypted_credentials, entity.encryption_salt) == {
"api_key": "didar-secret"
}
tools = service.list_tools(response.connector_id)
assert tools.state == "not_mcp"
assert tools.tools == []
functions = service.list_native_functions(response.connector_id)
assert functions["provider"] == "didar"
assert any(item["name"] == "search_deals" and item["enabled"] for item in functions["functions"])
assert "input_schema" not in json.dumps(functions)
def test_native_create_does_not_persist_when_health_check_fails(
service: ConnectorService, monkeypatch: pytest.MonkeyPatch
):
_enable_didar(monkeypatch)
from kamidso.native_connectors.executor import NativeConnectorExecutor
async def _probe(self, provider_id, credentials):
return {"success": False, "message": "invalid api key"}
monkeypatch.setattr(NativeConnectorExecutor, "probe", _probe)
with pytest.raises(ValueError, match="invalid api key"):
service.create_connector(
ConnectorCreateRequest(
connector_type="didar",
display_name="دیدار فروش",
credentials={"api_key": "bad"},
)
)
with db.session() as session:
assert session.query(ConnectorInstanceEntity).count() == 0
def test_native_create_requires_durable_key_in_production(
service: ConnectorService, monkeypatch: pytest.MonkeyPatch
):
_enable_didar(monkeypatch)
monkeypatch.setenv("KAMIDSO_ENV", "production")
monkeypatch.setattr(service._credential_store, "has_durable_key", lambda: False)
from kamidso.native_connectors.executor import NativeConnectorExecutor
called = {"probe": False}
async def _probe(self, provider_id, credentials):
called["probe"] = True
return {"success": True, "message": "ok"}
monkeypatch.setattr(NativeConnectorExecutor, "probe", _probe)
with pytest.raises(ValueError, match="ENCRYPT_KEY"):
service.create_connector(
ConnectorCreateRequest(
connector_type="didar",
display_name="دیدار فروش",
credentials={"api_key": "didar-secret"},
)
)
assert called["probe"] is False
def test_after_start_skips_native_connectors(
service: ConnectorService, monkeypatch: pytest.MonkeyPatch
):
_enable_didar(monkeypatch)
store = CredentialStore(system_app=service.system_app)
salt = store.generate_salt()
with db.session() as session:
session.add(
ConnectorInstanceEntity(
connector_id="didar-1",
connector_type="didar",
display_name="دیدار فروش",
encrypted_credentials=store.encrypt({"api_key": "didar-secret"}, salt),
encryption_salt=salt,
status="active",
)
)
fake_manager = MagicMock()
fake_manager.create_connector = AsyncMock()
def _fake_get_component(name, component_type, default_component=None):
if name == "connector_manager":
return fake_manager
return default_component
monkeypatch.setattr(service.system_app, "get_component", _fake_get_component)
service.after_start()
fake_manager.create_connector.assert_not_called()
def test_retire_connector_keeps_the_row(service: ConnectorService):
with db.session() as session:
session.add(
ConnectorInstanceEntity(
connector_id="didar-keep",
connector_type="didar",
display_name="دیدار فروش",
status="active",
user_name="usr_1",
)
)
service.retire_connector("didar-keep")
kept = service.get_connector("didar-keep")
assert kept is not None
assert kept.status == "deleted"
+43 -1
View File
@@ -13,7 +13,11 @@ from kamidso.contracts.chat import KamidsoChatRequest
from kamidso.contracts.resource import ResourceRef from kamidso.contracts.resource import ResourceRef
from kamidso.contracts.run import KamidsoRun from kamidso.contracts.run import KamidsoRun
from kamidso.contracts.user import CurrentUser from kamidso.contracts.user import CurrentUser
from kamidso.integrations.engine.events import map_engine_event from kamidso.integrations.engine.events import (
begin_adapter_run,
map_engine_event,
release_adapter_run,
)
from kamidso.integrations.engine.runtime import EngineRuntime from kamidso.integrations.engine.runtime import EngineRuntime
from kamidso.resources.resolver import MaterializedResource from kamidso.resources.resolver import MaterializedResource
from kamidso.settings.flags import KamidsoSettings from kamidso.settings.flags import KamidsoSettings
@@ -74,6 +78,32 @@ class KamidsoEngine:
} }
return return
begin_adapter_run(run.id)
try:
async for event in self._execute_ready(
user=user,
run=run,
request=request,
materialized=materialized,
prior_history=prior_history,
last_report=last_report,
):
yield event
finally:
# Failure, cancellation, and client disconnect all land here,
# after the last event has already been yielded.
release_adapter_run(run.id)
async def _execute_ready(
self,
*,
user: CurrentUser,
run: KamidsoRun,
request: KamidsoChatRequest,
materialized: List[MaterializedResource],
prior_history: Optional[List[Dict[str, str]]] = None,
last_report: Optional[Dict[str, Any]] = None,
) -> AsyncIterator[Dict[str, Any]]:
from kamidso.engine.app.openapi.api_v1.agentic_data_api import ( from kamidso.engine.app.openapi.api_v1.agentic_data_api import (
_open_turn_attachments, _open_turn_attachments,
_react_agent_stream, _react_agent_stream,
@@ -109,6 +139,9 @@ class KamidsoEngine:
ext_info["knowledge_space_name"] = kb_ids[0] ext_info["knowledge_space_name"] = kb_ids[0]
if kb_label: if kb_label:
ext_info["knowledge_space_label"] = kb_label ext_info["knowledge_space_label"] = kb_label
connector_refs = [ref for ref in request.resources if ref.type == "connector" and ref.id]
if connector_refs:
ext_info.update(connector_selection(connector_refs))
context = self._builder.build( context = self._builder.build(
user, user,
@@ -226,6 +259,15 @@ class KamidsoEngine:
logger.warning("Failed to close ReAct agent stream", exc_info=True) logger.warning("Failed to close ReAct agent stream", exc_info=True)
def connector_selection(resources: List[Any]) -> Dict[str, List[str]]:
"""Map attached connector resources onto the existing connector_ids field."""
refs = [ref for ref in resources if getattr(ref, "type", None) == "connector" and getattr(ref, "id", None)]
return {
"connector_ids": [ref.id for ref in refs],
"connector_labels": [ref.name or ref.id for ref in refs],
}
def _stage_files_for_engine( def _stage_files_for_engine(
materialized: List[MaterializedResource], materialized: List[MaterializedResource],
) -> tuple[List[str], List[str]]: ) -> tuple[List[str], List[str]]:
+110 -4
View File
@@ -1,15 +1,121 @@
"""Map engine SSE payloads to product events, and forget finished runs."""
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List import logging
import os
import threading
import time
from typing import Any, Dict, List, Optional
from kamidso.integrations.event_adapter import EventAdapterState from kamidso.integrations.event_adapter import EventAdapterState
logger = logging.getLogger("kamidso.engine")
_ADAPTER_CACHE: Dict[str, EventAdapterState] = {}
_ACTIVE_RUNS: set[str] = set()
_TOUCHED: Dict[str, float] = {}
_LOCK = threading.Lock()
_SWEEPER_STARTED = False
_DEFAULT_TTL_SECONDS = 900.0
def adapter_cache_ttl_seconds() -> float:
raw = (os.environ.get("KAMIDSO_ADAPTER_CACHE_TTL_SECONDS") or "").strip()
if not raw:
return _DEFAULT_TTL_SECONDS
try:
return max(1.0, float(raw))
except ValueError:
logger.warning("Ignoring invalid KAMIDSO_ADAPTER_CACHE_TTL_SECONDS=%s", raw)
return _DEFAULT_TTL_SECONDS
def begin_adapter_run(run_id: str) -> None:
"""Keep this run's adapter alive for the whole execution, including long tools."""
with _LOCK:
_ACTIVE_RUNS.add(run_id)
_TOUCHED[run_id] = time.monotonic()
_ensure_sweeper_locked()
def release_adapter_run(run_id: str) -> None:
"""Drop adapter state after the last event for this run has been produced.
Safe on success, failure, cancellation, and generator close. Does not
remove a different run that is still active.
"""
with _LOCK:
_ACTIVE_RUNS.discard(run_id)
_ADAPTER_CACHE.pop(run_id, None)
_TOUCHED.pop(run_id, None)
def sweep_adapter_cache(*, now: Optional[float] = None, keep: Optional[str] = None) -> List[str]:
"""Forget inactive adapters that have not been touched within the TTL.
Active runs are kept even if they have been quiet, so a long SQL or code
step is not discarded mid-flight.
"""
moment = time.monotonic() if now is None else now
ttl = adapter_cache_ttl_seconds()
removed: List[str] = []
with _LOCK:
stale = [
run_id
for run_id, touched in _TOUCHED.items()
if run_id != keep and run_id not in _ACTIVE_RUNS and moment - touched >= ttl
]
for run_id in stale:
_ADAPTER_CACHE.pop(run_id, None)
_TOUCHED.pop(run_id, None)
removed.append(run_id)
return removed
def map_engine_event( def map_engine_event(
run_id: str, engine_event: Dict[str, Any], locale: str = "fa-IR" run_id: str, engine_event: Dict[str, Any], locale: str = "fa-IR"
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
adapter = _ADAPTER_CACHE.setdefault(run_id, EventAdapterState(run_id, locale=locale)) with _LOCK:
return adapter.map(engine_event) _TOUCHED[run_id] = time.monotonic()
adapter = _ADAPTER_CACHE.get(run_id)
if adapter is None:
adapter = EventAdapterState(run_id, locale=locale)
_ADAPTER_CACHE[run_id] = adapter
_ensure_sweeper_locked()
events = adapter.map(engine_event)
sweep_adapter_cache(keep=run_id)
return events
_ADAPTER_CACHE: Dict[str, EventAdapterState] = {} def adapter_cache_size() -> int:
with _LOCK:
return len(_ADAPTER_CACHE)
def adapter_run_is_active(run_id: str) -> bool:
with _LOCK:
return run_id in _ACTIVE_RUNS
def _ensure_sweeper_locked() -> None:
global _SWEEPER_STARTED
if _SWEEPER_STARTED:
return
_SWEEPER_STARTED = True
thread = threading.Thread(
target=_sweep_loop,
name="kamidso-adapter-cache",
daemon=True,
)
thread.start()
def _sweep_loop() -> None:
while True:
ttl = adapter_cache_ttl_seconds()
time.sleep(min(60.0, ttl))
try:
sweep_adapter_cache()
except Exception:
logger.debug("adapter cache sweep failed", exc_info=True)
+5
View File
@@ -0,0 +1,5 @@
"""Predefined business-app connectors. Independent of MCP."""
from kamidso.native_connectors.registry import get_registry, reset_registry
__all__ = ["get_registry", "reset_registry"]
+144
View File
@@ -0,0 +1,144 @@
"""Error taxonomy for native connector calls."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
class NativeConnectorError(Exception):
error_type = "connector_error"
http_sent = False
def __init__(
self,
message: str,
*,
function: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(message)
self.message = message
self.function = function
self.details = details or {}
def to_payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"ok": False,
"error_type": self.error_type,
"message": self.message,
"http_sent": self.http_sent,
}
if self.function:
payload["function"] = self.function
payload.update(self.details)
return payload
class CatalogError(NativeConnectorError):
error_type = "catalog_error"
class ConnectorNotAttached(NativeConnectorError):
error_type = "connector_not_attached"
class ConnectorNotFound(NativeConnectorError):
error_type = "connector_not_found"
class ConnectorAmbiguous(NativeConnectorError):
error_type = "connector_ambiguous"
def __init__(self, names: List[str]) -> None:
listing = "\n".join(f"- {name}" for name in names)
super().__init__(
"Several native connectors are attached.\n\nSpecify connection:\n" + listing,
details={"connections": names},
)
class ProviderNotFound(NativeConnectorError):
error_type = "provider_not_found"
class FunctionNotFound(NativeConnectorError):
error_type = "unknown_function"
def __init__(self, function: str, provider_label: str, suggestions: List[str]) -> None:
super().__init__(
f"Function '{function}' does not exist for {provider_label}.",
function=function,
details={
"suggestions": suggestions,
"instruction": "Use only predefined function names.",
},
)
class FunctionDisabled(NativeConnectorError):
error_type = "function_disabled"
class OperationNotAllowed(NativeConnectorError):
error_type = "operation_not_allowed"
def __init__(self, function: str, effect: str) -> None:
super().__init__(
"This connector is read-only. No HTTP request was sent.",
function=function,
details={"effect": effect},
)
class ValidationFailed(NativeConnectorError):
error_type = "validation_error"
class AuthenticationFailed(NativeConnectorError):
"""Local credential failure. Remote 401 uses RemoteCallFailed."""
error_type = "authentication_error"
class RemoteCallFailed(NativeConnectorError):
def __init__(
self,
message: str,
*,
error_type: str,
function: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(message, function=function, details=details)
self.error_type = error_type
self.http_sent = True
class TransportFailed(NativeConnectorError):
error_type = "transport_error"
class CallTimeout(NativeConnectorError):
error_type = "timeout_error"
class ResponseTooLarge(NativeConnectorError):
error_type = "response_too_large"
http_sent = True
class InvalidJSONResponse(NativeConnectorError):
error_type = "invalid_json_response"
http_sent = True
class ResultMaterializationError(NativeConnectorError):
error_type = "result_materialization_error"
http_sent = True
class AccuracyGuard(NativeConnectorError):
"""An exact management figure cannot be verified. No substitute total is included."""
error_type = "accuracy_guard"
http_sent = True
+482
View File
@@ -0,0 +1,482 @@
"""Call a predefined native connector function. Credentials stay inside this module."""
from __future__ import annotations
import json
import logging
import time
from typing import Any, Callable, Dict, Optional
from urllib.parse import urlparse
import httpx
from kamidso.native_connectors.errors import (
AuthenticationFailed,
CallTimeout,
ConnectorNotFound,
FunctionDisabled,
FunctionNotFound,
InvalidJSONResponse,
NativeConnectorError,
OperationNotAllowed,
ProviderNotFound,
RemoteCallFailed,
ResponseTooLarge,
ResultMaterializationError,
TransportFailed,
)
from kamidso.native_connectors.models import ConnectorFunction, NativeProvider
from kamidso.native_connectors.redact import redact, redact_text, strip_sensitive_fields
from kamidso.native_connectors.registry import NativeConnectorRegistry, get_registry
from kamidso.native_connectors.result_store import (
materialize_connector_result,
render_success_observation,
)
from kamidso.native_connectors.validator import validate_body, validation_payload
logger = logging.getLogger(__name__)
_MAX_RESPONSE_BYTES = 5 * 1024 * 1024
_ATTEMPT_TIMEOUT = 30.0
_TOTAL_BUDGET = 60.0
_RETRY_STATUSES = {500, 502, 503}
RowLoader = Callable[[str], Optional[Dict[str, Any]]]
def _status_error_type(status_code: int) -> str:
if status_code == 401:
return "authentication_error"
if status_code == 403:
return "permission_error"
if status_code == 429:
return "rate_limit_error"
if status_code == 400:
return "remote_validation_error"
if status_code >= 500:
return "remote_server_error"
return "http_error"
def suggest_functions(provider: NativeProvider, name: str) -> list:
import difflib
enabled = [item.name for item in provider.enabled_reads()]
suggestions = list(provider.aliases.get(name) or [])
if "lead" in name.lower():
for candidate in ("search_persons", "search_deals"):
if candidate in enabled and candidate not in suggestions:
suggestions.append(candidate)
close = difflib.get_close_matches(name, enabled, n=3, cutoff=0.5)
for candidate in close:
if candidate not in suggestions:
suggestions.append(candidate)
return [item for item in suggestions if item in enabled][:3]
def default_row_loader(connector_id: str) -> Optional[Dict[str, Any]]:
from kamidso.engine.serve.connector.config import ServeConfig
from kamidso.engine.serve.connector.models.models import (
ConnectorInstanceDao,
ConnectorInstanceEntity,
)
dao = ConnectorInstanceDao(ServeConfig())
with dao.session() as session:
entity = (
session.query(ConnectorInstanceEntity)
.filter(ConnectorInstanceEntity.connector_id == connector_id)
.first()
)
if entity is None:
return None
config: Dict[str, Any] = {}
raw_config = entity.config_json
if isinstance(raw_config, str) and raw_config.strip():
try:
parsed = json.loads(raw_config)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, dict):
config = parsed
return {
"connector_id": entity.connector_id,
"connector_type": entity.connector_type,
"display_name": entity.display_name or "",
"status": entity.status or "",
"encrypted_credentials": entity.encrypted_credentials,
"encryption_salt": entity.encryption_salt,
"user_name": entity.user_name,
"sys_code": entity.sys_code,
"config": config,
}
class NativeConnectorExecutor:
def __init__(
self,
registry: Optional[NativeConnectorRegistry] = None,
*,
client: Optional[httpx.AsyncClient] = None,
credential_store: Any = None,
row_loader: Optional[RowLoader] = None,
) -> None:
self._registry = registry
self._client = client
self._credential_store = credential_store
self._row_loader = row_loader or default_row_loader
def load_row(self, connector_id: str) -> Optional[Dict[str, Any]]:
return self._row_loader(connector_id)
@property
def registry(self) -> NativeConnectorRegistry:
if self._registry is None:
self._registry = get_registry()
return self._registry
async def probe(self, provider_id: str, credentials: Dict[str, str]) -> Dict[str, Any]:
try:
provider = self.registry.get(provider_id)
except ProviderNotFound as exc:
return {"success": False, "message": exc.message}
try:
result = await self._call(
provider=provider,
function_name=provider.health_function,
body=dict(provider.health_body),
credentials=credentials,
react_state=None,
materialize=False,
display_name=provider.display_name,
)
except NativeConnectorError as exc:
payload = exc.to_payload()
status_code = payload.get("status_code")
return {
"success": False,
"message": redact_text(exc.message),
"status_code": status_code if isinstance(status_code, int) else None,
"error_type": exc.error_type,
}
if result.get("ok"):
return {"success": True, "message": "Connection successful"}
return {
"success": False,
"message": redact_text(str(result.get("message") or "Connection test failed")),
}
async def probe_stored(self, connector_id: str) -> Dict[str, Any]:
row = self._row_loader(connector_id)
if row is None:
return {"success": False, "message": f"Connector '{connector_id}' not found"}
try:
credentials = self._decrypt(row)
except NativeConnectorError as exc:
return {"success": False, "message": exc.message}
return await self.probe(str(row.get("connector_type") or ""), credentials)
async def execute(
self,
*,
connector_id: str,
function_name: str,
body: Any,
react_state: Optional[Dict[str, Any]],
display_name: Optional[str] = None,
) -> Dict[str, Any]:
row = self._row_loader(connector_id)
if row is None or str(row.get("status") or "") != "active":
raise ConnectorNotFound(f"Connector '{connector_id}' is not available.")
provider = self.registry.get(str(row.get("connector_type") or ""))
label = display_name or str(row.get("display_name") or provider.display_name)
return await self._call(
provider=provider,
function_name=function_name,
body=body,
credentials=None,
react_state=react_state,
materialize=True,
display_name=label,
audit_user=str((react_state or {}).get("user_name") or row.get("user_name") or ""),
connector_id=connector_id,
encrypted_row=row,
)
def _decrypt(self, row: Dict[str, Any]) -> Dict[str, str]:
if self._credential_store is None:
raise AuthenticationFailed("Credential store is not configured.")
try:
decrypted = self._credential_store.decrypt(
row.get("encrypted_credentials"),
row.get("encryption_salt"),
)
except Exception as exc:
raise AuthenticationFailed("Stored credentials could not be decrypted.") from exc
if not isinstance(decrypted, dict):
raise AuthenticationFailed("Stored credentials could not be decrypted.")
return {str(key): str(value) for key, value in decrypted.items()}
async def _call(
self,
*,
provider: NativeProvider,
function_name: str,
body: Any,
credentials: Optional[Dict[str, str]],
react_state: Optional[Dict[str, Any]],
materialize: bool,
display_name: str,
audit_user: str = "",
connector_id: str = "",
encrypted_row: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
function = provider.function(function_name)
if function is None:
raise FunctionNotFound(
function_name,
provider.display_name,
suggest_functions(provider, function_name),
)
if function.effect != "read":
raise OperationNotAllowed(function.name, function.effect)
if not function.enabled:
raise FunctionDisabled(
f"Function '{function.name}' is disabled.",
function=function.name,
)
normalized, issues = validate_body(function, body)
if issues:
return validation_payload(
connector_name=display_name,
function=function,
issues=issues,
)
assert normalized is not None
if credentials is None:
if encrypted_row is None:
raise AuthenticationFailed(
"The connector credential is missing.",
function=function.name,
)
credentials = self._decrypt(encrypted_row)
secret = str(credentials.get(provider.credential_field) or "").strip()
if not secret:
raise AuthenticationFailed(
"The connector credential is missing.",
function=function.name,
)
started = time.perf_counter()
response, request_size = await self._send(
provider, function, strip_sensitive_fields(normalized), secret
)
duration_ms = int((time.perf_counter() - started) * 1000)
response_size = len(response.content or b"")
self._audit(
react_state=react_state,
user_name=audit_user,
connector_id=connector_id,
connector_type=provider.id,
function=function,
status_code=response.status_code,
duration_ms=duration_ms,
request_size=request_size,
response_size=response_size,
)
if response_size > _MAX_RESPONSE_BYTES:
raise ResponseTooLarge(
"Use narrower filters or pagination.",
function=function.name,
details={"status_code": response.status_code},
)
if response.status_code in {301, 302, 303, 307, 308}:
raise RemoteCallFailed(
"The connector refused a redirect.",
error_type="http_error",
function=function.name,
details={
"provider": provider.id,
"status_code": response.status_code,
"body": None,
},
)
payload = self._decode_json(response, function.name)
if response.status_code >= 400:
details: Dict[str, Any] = {
"provider": provider.id,
"status_code": response.status_code,
"body": redact(payload),
}
retry_after = response.headers.get("Retry-After")
if response.status_code == 429 and retry_after:
details["retry_after"] = retry_after
raise RemoteCallFailed(
f"The connector returned HTTP {response.status_code}.",
error_type=_status_error_type(response.status_code),
function=function.name,
details=details,
)
if not materialize or react_state is None:
return {
"ok": True,
"http_sent": True,
"status_code": response.status_code,
"body": payload,
}
try:
meta = materialize_connector_result(react_state, payload)
except OSError as exc:
raise ResultMaterializationError(
"The connector response could not be saved.",
function=function.name,
) from exc
observation = render_success_observation(
provider=provider,
function=function,
body=normalized,
status_code=response.status_code,
payload=payload,
relative_path=meta["relative_path"],
)
return {
"ok": True,
"http_sent": True,
"status_code": response.status_code,
"relative_path": meta["relative_path"],
"observation": observation,
}
def _decode_json(self, response: httpx.Response, function_name: str) -> Any:
if not response.content:
return {}
try:
return response.json()
except (json.JSONDecodeError, ValueError) as exc:
raise InvalidJSONResponse(
"The connector returned a non-JSON response.",
function=function_name,
details={"status_code": response.status_code},
) from exc
async def _send(
self,
provider: NativeProvider,
function: ConnectorFunction,
body: Dict[str, Any],
secret: str,
):
url = _safe_url(provider, function.path)
params = {provider.auth_parameter: secret}
headers = dict(provider.default_headers)
request_size = len(json.dumps(body, ensure_ascii=False).encode("utf-8"))
deadline = time.monotonic() + _TOTAL_BUDGET
attempt = 0
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise CallTimeout(
"The connector request exceeded the time budget.",
function=function.name,
)
timeout = httpx.Timeout(min(_ATTEMPT_TIMEOUT, remaining))
try:
response = await self._request(
function.method,
url,
params=params,
headers=headers,
body=body,
timeout=timeout,
)
except httpx.TimeoutException as exc:
raise CallTimeout(
"The connector request timed out.",
function=function.name,
) from exc
except httpx.TransportError as exc:
if attempt == 0 and _retryable_transport(exc) and deadline - time.monotonic() > 0:
attempt += 1
continue
raise TransportFailed(
redact_text(str(exc)) or "The connector request failed.",
function=function.name,
) from exc
if response.status_code in _RETRY_STATUSES and attempt == 0 and deadline - time.monotonic() > 0:
attempt += 1
continue
return response, request_size
async def _request(
self,
method: str,
url: str,
*,
params: Dict[str, str],
headers: Dict[str, str],
body: Dict[str, Any],
timeout: httpx.Timeout,
) -> httpx.Response:
kwargs: Dict[str, Any] = {
"params": params,
"headers": headers,
"timeout": timeout,
"follow_redirects": False,
}
if method.upper() != "GET":
kwargs["json"] = body
async def _send(client: httpx.AsyncClient) -> httpx.Response:
return await client.request(method, url, **kwargs)
if self._client is not None:
return await _send(self._client)
async with httpx.AsyncClient(follow_redirects=False) as client:
return await _send(client)
def _audit(
self,
*,
react_state: Optional[Dict[str, Any]],
user_name: str,
connector_id: str,
connector_type: str,
function: ConnectorFunction,
status_code: int,
duration_ms: int,
request_size: int,
response_size: int,
) -> None:
logger.info(
"native_connector conversation_id=%s user_name=%s connector_id=%s "
"connector_type=%s function=%s effect=%s http_status=%s duration_ms=%s "
"request_size=%s response_size=%s cache_hit=%s",
(react_state or {}).get("conv_id") or "",
user_name,
connector_id,
connector_type,
function.name,
function.effect,
status_code,
duration_ms,
request_size,
response_size,
False,
)
def _retryable_transport(exc: httpx.TransportError) -> bool:
if isinstance(exc, httpx.TimeoutException):
return False
return isinstance(exc, (httpx.NetworkError, httpx.RemoteProtocolError))
def _safe_url(provider: NativeProvider, path: str) -> str:
base = urlparse(provider.base_url)
if base.scheme != "https" or not base.hostname:
raise TransportFailed("The connector host is not allowed.")
if not path.startswith("/") or "://" in path or ".." in path.split("/"):
raise TransportFailed("The connector path is not allowed.")
url = provider.base_url.rstrip("/") + path
parsed = urlparse(url)
if parsed.scheme != "https" or parsed.hostname != base.hostname:
raise TransportFailed("The connector host is not allowed.")
return url
+21
View File
@@ -0,0 +1,21 @@
"""Feature flags for native business connectors."""
from __future__ import annotations
import os
def _truthy(name: str, default: str = "0") -> bool:
return os.environ.get(name, default).strip().lower() in {"1", "true", "yes", "on"}
def native_connectors_v1_enabled() -> bool:
return _truthy("KAMIDSO_NATIVE_CONNECTORS_V1", "0")
def didar_connector_enabled() -> bool:
return _truthy("KAMIDSO_DIDAR_CONNECTOR", "0")
def is_production() -> bool:
return os.environ.get("KAMIDSO_ENV", "").strip().lower() == "production"
+59
View File
@@ -0,0 +1,59 @@
"""In-memory catalog models. Loaded from provider files, not from the network."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass(frozen=True)
class ConnectorFunction:
name: str
title: str
description: str
domain: str
effect: str
enabled: bool
method: str
path: str
input_schema: Dict[str, Any]
response_root: str
records_path: Optional[str] = None
summary_fields: List[str] = field(default_factory=list)
semantic_hints: List[str] = field(default_factory=list)
pagination: Optional[Dict[str, Any]] = None
content_type: str = "application/json"
timeout_seconds: int = 30
@dataclass(frozen=True)
class NativeProvider:
id: str
version: str
display_name: str
category: str
base_url: str
auth_type: str
auth_parameter: str
credential_field: str
default_headers: Dict[str, str]
mode: str
health_function: str
health_body: Dict[str, Any]
aliases: Dict[str, List[str]]
guidance: str
functions: List[ConnectorFunction]
description: str = ""
def function(self, name: str) -> Optional[ConnectorFunction]:
for item in self.functions:
if item.name == name:
return item
return None
def enabled_reads(self) -> List[ConnectorFunction]:
return [
item
for item in self.functions
if item.enabled and item.effect == "read"
]
+110
View File
@@ -0,0 +1,110 @@
"""Prompt text for attached native connectors. Names and descriptions only."""
from __future__ import annotations
import re
from typing import Any, Dict, Iterable, List
from kamidso.native_connectors.models import ConnectorFunction
from kamidso.native_connectors.registry import get_registry
_PATH_MENTION = re.compile(
r"(?i)\b(?:GET|POST|PUT|PATCH|DELETE|HEAD)\s+/api/\S+|/api/\S+"
)
_DOMAIN_LABELS = {
"activity": "Activities",
"case": "Cases",
"company": "Companies",
"deal": "Deals",
"invoice": "Invoices",
"location": "Locations",
"note": "Notes",
"person": "People",
"pipeline": "Pipelines",
"product": "Products",
"user": "Users",
}
_RULES = """Native connector functions are predefined.
Never invent a connector function.
Use connector_call to fetch business data.
Do not use code_interpreter to call the external API.
Use code_interpreter only after connector_call when computation, dataframe operations, merging, charts, statistics or export are required.
If connector_call returns validation_error:
1. Read the returned schema.
2. Fix the body.
3. Retry the SAME function.
Do not create a different function to work around a malformed request.
A validation_error means the selected function exists and is appropriate enough to validate.
Prefer server-provided totals and aggregates from connector responses.
Do not paginate through all records merely to count them.
Didar data is fetched with connector_call. Database data uses database(). Excel and CSV files use code_interpreter.
For Didar management questions, call a semantic operation and pass a business body. Do not build Didar Criteria.
sales_summary answers فروش in a period and means deals that became Won in that period. Example body: {"period": "مهر ۱۴۰۵"}.
deals_created answers معاملات in a period and means deals registered in that period. State the period basis.
Other semantic operations: won_deals, lost_deals, pending_pipeline_snapshot, sales_by_owner, sales_over_time.
Keep every accuracy warning in the answer, including mode, date basis, currency, and panel parity.
Current deal status comes only from Status. ChangeToWonTime never overrides Pending.
Response.List is one page. Totals come from aggregates such as WonDealsCount, PendingDealsCount, and LostDealsCount.
Do not invent get_leads, search_leads, or list_leads. lead_count stays disabled.
Do not sum amounts that have different CurrencyId values, and do not guess a currency name.
On raw search calls, From and Limit are top-level siblings of Criteria, not fields inside Criteria.
"""
def _domain_label(domain: str) -> str:
return _DOMAIN_LABELS.get(domain, domain.replace("_", " ").title() or "Other")
def _public_description(description: str) -> str:
cleaned = _PATH_MENTION.sub("", description)
return " ".join(cleaned.split())
def _function_lines(functions: Iterable[ConnectorFunction]) -> List[str]:
grouped: Dict[str, List[ConnectorFunction]] = {}
for function in functions:
grouped.setdefault(function.domain or "other", []).append(function)
lines: List[str] = []
for domain, items in grouped.items():
lines.append(_domain_label(domain))
for function in items:
lines.append(f"- {function.name} — {_public_description(function.description)}")
lines.append("")
return lines
def render_native_prompt(instances: List[Dict[str, Any]]) -> str:
if not instances:
return ""
registry = get_registry()
blocks: List[str] = [
"## Connected Business Apps",
"",
_RULES.strip(),
"",
]
by_type: Dict[str, List[Dict[str, Any]]] = {}
for instance in instances:
by_type.setdefault(str(instance.get("connector_type") or ""), []).append(instance)
for connector_type, group in by_type.items():
if not registry.contains(connector_type):
continue
provider = registry.get(connector_type)
if provider.guidance:
blocks.append(provider.guidance)
blocks.append("")
for instance in group:
blocks.extend(
[
f"### {instance.get('display_name') or instance.get('connector_id')}",
f"Provider: {provider.display_name}",
"Access: Read only",
"",
"Available functions:",
"",
]
)
blocks.extend(_function_lines(provider.enabled_reads()))
return "\n".join(blocks).strip()
@@ -0,0 +1,94 @@
{
"schema_version": "1.0",
"provider": "didar",
"storage_target": "connector_instance.config_json.semantic_calibration",
"schema": {
"type": "object",
"properties": {
"timezone": {
"type": "string",
"default": "Asia/Tehran"
},
"calendar": {
"type": "string",
"enum": [
"jalali",
"gregorian"
],
"default": "jalali"
},
"base_currency_id": {
"type": [
"integer",
"string",
"null"
]
},
"base_currency_code": {
"type": [
"string",
"null"
]
},
"base_currency_name": {
"type": [
"string",
"null"
]
},
"display_unit": {
"type": [
"string",
"null"
]
},
"currency_map": {
"type": "object"
},
"verified_reports": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"verified": {
"type": "boolean"
},
"verified_at": {
"type": [
"string",
"null"
]
},
"panel_sample_id": {
"type": [
"string",
"null"
]
},
"notes": {
"type": [
"string",
"null"
]
}
},
"required": [
"verified"
]
}
}
},
"additionalProperties": false
},
"default": {
"timezone": "Asia/Tehran",
"calendar": "jalali",
"base_currency_id": null,
"base_currency_code": null,
"base_currency_name": null,
"display_unit": null,
"currency_map": {},
"verified_reports": {}
},
"rule": "Calibration is connection-specific. Do not put account-specific currency mappings or verification flags in the global provider files."
}
@@ -0,0 +1,93 @@
{
"schema_version": "1.0",
"provider": "didar",
"default_user_calendar": "jalali",
"default_business_timezone": "Asia/Tehran",
"api_timestamp_timezone": "UTC",
"range_semantics": "[start, end)",
"parser_requirements": {
"accept_persian_digits": true,
"accept_latin_digits": true,
"accept_persian_month_names": true,
"accept_relative_periods": [
"امروز",
"دیروز",
"این هفته",
"هفته قبل",
"این ماه",
"ماه قبل",
"امسال",
"سال قبل",
"سه ماه اخیر",
"شش ماه اخیر"
]
},
"jalali_months": {
"فروردین": 1,
"اردیبهشت": 2,
"خرداد": 3,
"تیر": 4,
"مرداد": 5,
"شهریور": 6,
"مهر": 7,
"آبان": 8,
"آذر": 9,
"دی": 10,
"بهمن": 11,
"اسفند": 12
},
"period_resolution": {
"year_omitted": "Use the current Jalali year in the conversation/business timezone unless context clearly names another year.",
"month_period": "Start at 00:00:00 local time on day 1; end at 00:00:00 local time on day 1 of the next Jalali month.",
"utc_conversion": "Convert local start/end to UTC before calling Didar.",
"output_metadata": [
"input_text",
"calendar",
"timezone",
"start_local",
"end_local_exclusive",
"start_utc",
"end_utc_exclusive"
]
},
"deal_date_basis": {
"sales": "won_at",
"won_deals": "won_at",
"lost_deals": "lost_at",
"deals_created": "created_at",
"pending_pipeline_snapshot": "current_snapshot"
},
"didar_search_deals_strategy": {
"created_at": {
"Sort": 0,
"SearchFromTime": "start_utc",
"SearchToTime": "end_utc_exclusive",
"confidence": "documented"
},
"won_at": {
"Sort": 9,
"SearchFromTime": "start_utc",
"SearchToTime": "end_utc_exclusive",
"confidence": "needs_real_account_reconciliation",
"verification": [
"Every returned row used for a won-period report must have Status == Won.",
"Every returned row used for a won-period report must have ChangeToWonTime inside [start_utc, end_utc_exclusive).",
"If returned rows violate the period basis, do not silently report the aggregate as exact."
],
"fallback": {
"mode": "exact_local_filter_when_feasible",
"description": "Fetch Won deals in controlled pages and post-filter by ChangeToWonTime when the server date-filter semantics are not reconciled.",
"max_records": 5000,
"over_cap": "Return an accuracy_guard error rather than an unverified exact number."
}
},
"lost_at": {
"confidence": "not_verified_by_public_contract",
"fallback": "Use Status=Lost and locally filter ChangeToLossTime after controlled pagination until a real-account contract test establishes server-side date behavior."
}
},
"implementation": {
"recommended_python_dependency": "jdatetime or another tested Jalali conversion library",
"rule": "Pin the chosen Jalali library version and cover Esfand/leap-year/month-boundary behavior with tests."
}
}
@@ -0,0 +1,67 @@
{
"schema_version": "1.0",
"provider": "didar",
"authoritative": {
"deal_status": {
"Pending": {
"canonical": "pending",
"fa": "جاری",
"meaning": "Current/open deal"
},
"Won": {
"canonical": "won",
"fa": "موفق",
"meaning": "Won/successful deal"
},
"Lost": {
"canonical": "lost",
"fa": "ناموفق",
"meaning": "Lost/failed deal"
}
},
"deal_search_sort": {
"0": "register_time",
"1": "last_tracking_time",
"2": "next_tracking_time",
"3": "has_quote",
"4": "has_invoice",
"5": "without_invoice",
"6": "has_void_invoice",
"7": "has_quote_with_price",
"8": "has_quote_without_price",
"9": "won_time",
"10": "next_activity_time"
},
"custom_field_operators": [
"NotSet",
"Set",
"EqualToAny",
"NotEqualAny",
"ContainsAnyString",
"NotContainsAnyString",
"StartsWith",
"LessThan",
"GreaterThan",
"Between"
]
},
"observed_not_authoritative": {
"contact_type": [
"General",
"Customer",
"Prospect"
],
"lead_type_examples": [
"General"
],
"lead_status_examples": [
"None",
"NotHandle"
]
},
"rules": [
"Deal Status is authoritative for current deal status.",
"IsDeleted is a separate deletion dimension and must not be collapsed into deal status.",
"Do not infer Won from PipelineStageId, Probability, labels, or ChangeToWonTime."
]
}
@@ -0,0 +1,398 @@
{
"schema_version": "1.0",
"provider": "didar",
"purpose": "Merge these accuracy-critical contracts into the existing 49-function catalog. Do not replace correct existing definitions blindly.",
"patches": [
{
"name": "search_deals",
"method": "POST",
"path": "/api/deal/search_v2",
"effect": "read",
"input_schema": {
"type": "object",
"properties": {
"Criteria": {
"type": "object",
"properties": {
"SearchFromTime": {
"type": "string",
"format": "date-time"
},
"SearchToTime": {
"type": "string",
"format": "date-time"
},
"Sort": {
"type": "integer",
"minimum": 0,
"maximum": 10
},
"ContactIds": {
"type": "array",
"items": {
"type": "string"
}
},
"CompanyIds": {
"type": "array",
"items": {
"type": "string"
}
},
"OtherContactIds": {
"type": "array",
"items": {
"type": "string"
}
},
"Keywords": {
"type": "string"
},
"LabelIds": {
"type": "array",
"items": {
"type": "string"
}
},
"Status": {
"type": "string",
"enum": [
"Pending",
"Won",
"Lost"
]
},
"CustomFields": {
"type": "array",
"items": {
"type": "object",
"properties": {
"CustomFieldId": {
"type": "string"
},
"OR": {
"type": "array",
"items": {
"type": "object",
"properties": {
"Type": {
"type": "string",
"enum": [
"NotSet",
"Set",
"EqualToAny",
"NotEqualAny",
"ContainsAnyString",
"NotContainsAnyString",
"StartsWith",
"LessThan",
"GreaterThan",
"Between"
]
},
"Value": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"Type",
"Value"
],
"additionalProperties": false
}
}
},
"required": [
"CustomFieldId",
"OR"
],
"additionalProperties": false
}
},
"PipelineId": {
"type": "string"
},
"PipelineStageId": {
"type": "string"
},
"SourceId": {
"type": "string"
},
"IsDeleted": {
"type": "boolean"
},
"HasActivity": {
"type": "boolean"
},
"LostReasonId": {
"type": "string"
},
"NextActivityFrom": {
"type": "string",
"format": "date-time"
},
"NextActivityTo": {
"type": "string",
"format": "date-time"
},
"IsIdle": {
"type": "boolean"
},
"IsRotten": {
"type": "boolean"
},
"Probability": {
"type": "string"
},
"OwnerIds": {
"type": "array",
"items": {
"type": "string"
}
},
"PipelineStageIds": {
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
},
"From": {
"type": "integer",
"minimum": 0
},
"Limit": {
"type": "integer",
"minimum": 1
}
},
"required": [
"Criteria",
"From",
"Limit"
],
"additionalProperties": false
},
"response_contract": {
"root": "Response",
"records_path": "Response.List",
"total_count": "Response.TotalCount",
"aggregates": [
"Response.TotalPrice",
"Response.AllDealsTotalPrice",
"Response.AllDealsCount",
"Response.PendingDealsTotalPrice",
"Response.PendingDealsCount",
"Response.WonDealsTotalPrice",
"Response.WonDealsCount",
"Response.LostDealsTotalPrice",
"Response.LostDealsCount"
]
},
"semantic_hints": [
"From and Limit are top-level fields, not inside Criteria.",
"Status accepts Pending, Won, Lost.",
"Sort=0 means registration time; Sort=9 means won time.",
"Use aggregate counts for totals; Response.List is only the current page.",
"Current status must come from each deal's Status field."
]
},
{
"name": "search_persons",
"method": "POST",
"path": "/api/contact/PersonSearch",
"effect": "read",
"input_schema": {
"type": "object",
"properties": {
"Criteria": {
"type": "object",
"properties": {
"IsDeleted": {
"type": "integer",
"enum": [
0,
1
]
},
"IsPinned": {
"type": "integer"
},
"IsVIP": {
"type": "integer"
},
"SortOrder": {
"type": "integer"
},
"Keywords": {
"type": [
"string",
"null"
]
},
"MobilePhone": {
"type": "string"
},
"WorkPhone": {
"type": "string"
},
"Email": {
"type": "string"
},
"NationalCode": {
"type": "string"
},
"ZipCode": {
"type": "string"
},
"CustomerCode": {
"type": "string"
},
"SearchFromTime": {
"type": "string",
"format": "date-time"
},
"SearchToTime": {
"type": "string",
"format": "date-time"
},
"CustomFields": {
"type": "array"
}
},
"additionalProperties": true
},
"From": {
"type": "integer",
"minimum": 0
},
"Limit": {
"type": "integer",
"minimum": 1
}
},
"required": [
"Criteria",
"From",
"Limit"
],
"additionalProperties": false
},
"response_contract": {
"root": "Response",
"records_path": "Response.List",
"total_count": "Response.TotalCount"
}
},
{
"name": "get_users",
"method": "POST",
"path": "/api/User/List",
"effect": "read",
"input_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"response_contract": {
"root": "Response",
"records_path": "Response",
"owner_key": "UserId",
"display_field": "DisplayName"
}
},
{
"name": "get_deal",
"method": "POST",
"path": "/api/deal/getdealdetail",
"effect": "read",
"input_schema": {
"type": "object",
"properties": {
"Id": {
"type": "string"
}
},
"required": [
"Id"
],
"additionalProperties": false
},
"semantic_hints": [
"Status is authoritative for current deal status.",
"Do not infer Won merely because ChangeToWonTime has a value; official response examples can contain ChangeToWonTime while Status is Pending."
]
},
{
"name": "get_deal_pipelines",
"method": "POST",
"path": "/api/pipeline/list/0",
"effect": "read",
"input_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"response_contract": {
"root": "Response",
"records_path": "Response",
"pipeline_id": "Response[].Id",
"stage_id": "Response[].Stages[].Id",
"stage_title": "Response[].Stages[].Title"
}
},
{
"name": "search_products",
"method": "POST",
"path": "/api/product/search",
"effect": "read",
"input_schema": {
"type": "object",
"properties": {
"Criteria": {
"type": "object",
"properties": {
"Keywords": {
"type": "string"
}
},
"required": [
"Keywords"
],
"additionalProperties": false
},
"From": {
"type": "integer",
"minimum": 0
},
"Limit": {
"type": "integer",
"minimum": 1
}
},
"required": [
"Criteria",
"From",
"Limit"
],
"additionalProperties": false
}
},
{
"name": "get_product_categories",
"method": "POST",
"path": "/api/product/categories",
"effect": "read",
"input_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
}
]
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,43 @@
{
"schema_version": "1.0",
"provider": "didar",
"description": "Fill this file with values read from the real Didar panel for one test account. Do not commit real customer identifiers or secrets.",
"test_account_alias": "staging-or-sanitized-account",
"timezone": "Asia/Tehran",
"calendar": "jalali",
"currency_profile": {
"base_currency_id": null,
"base_currency_code": null,
"base_currency_name": null,
"display_unit": null
},
"periods": [
{
"id": "mehr_sample",
"period_text": "مهر ۱۴۰۵",
"panel_expected": {
"sales_won_count": null,
"sales_amount": null,
"currency_display": null,
"won_count": null,
"lost_count": null,
"pending_count_snapshot": null,
"sales_by_owner": []
},
"kamidso_actual": {},
"tolerance": {
"count": 0,
"amount_absolute": 0,
"amount_relative": 0
},
"result": "not_run"
}
],
"verification_rules": [
"Counts must match exactly.",
"Money must match within configured tolerance only after currency semantics are known.",
"For sales reports, compare against Didar 'گزارش کلی فروش' for the same period.",
"Record the date basis used by Kamidso.",
"If a mismatch exists, inspect raw response, resolved UTC boundaries, Status, ChangeToWonTime, CurrencyId, and API-key visibility/access before changing semantics."
]
}
@@ -0,0 +1,43 @@
# Didar Semantic Guidance — compact provider guidance
این فایل برای تزریق کامل به System Prompt نیست. Runtime باید فقط بخش کوتاه و مرتبط را inject کند.
## قواعد قطعی
1. وضعیت فعلی معامله فقط از `Status` خوانده می‌شود:
- `Pending` = جاری
- `Won` = موفق
- `Lost` = ناموفق
2. `IsDeleted` یک بعد جدا است. حذف‌شده را با `Lost` یکی نکن.
3. از `ChangeToWonTime`، `PipelineStageId`، `Probability` یا Label برای استنتاج Won/Pending استفاده نکن.
4. `Response.List` در `search_deals` فقط صفحه فعلی است. برای شمارش کل از aggregateهای Response مانند `WonDealsCount` استفاده کن.
5. «فروش در بازه» یعنی معاملات موفق‌شده در آن بازه، نه معاملات ایجادشده در آن بازه.
6. «معاملات ثبت‌شده/ایجادشده در بازه» بر اساس `RegisterTime` است و وضعیت نمایش‌داده‌شده باید وضعیت فعلی `Status` باشد.
7. «معاملات موفق در بازه» بر اساس `ChangeToWonTime` است.
8. «معاملات ناموفق در بازه» بر اساس `ChangeToLossTime` است.
9. برای نام کاربران `OwnerId` را با `get_users` resolve کن؛ قرارداد اتصال را با Golden Test واقعی کنترل کن.
10. برای نام کاریز/مرحله از `get_deal_pipelines` استفاده کن.
## تاریخ
- ورودی فارسی مثل «مهر ۱۴۰۵» را در `Asia/Tehran` و تقویم جلالی resolve کن.
- بازه را `[start, end)` نگه دار و سپس به UTC تبدیل کن.
- در پاسخ مدیریتی مبنای تاریخ را صریح کن: «مبنای بازه: تاریخ موفق‌شدن» یا «مبنای بازه: تاریخ ثبت».
## پول
- بدون mapping معتبر `CurrencyId` هرگز اسم واحد پول را حدس نزن.
- مبالغ چند CurrencyId را خام با هم جمع نکن.
- گزارش‌های پنل دیدار از قابلیت چندارزی و ارز مبنا استفاده می‌کنند؛ برابری aggregate API با پنل باید با Golden Reconciliation برای اکانت تأیید شود.
- تا قبل از Reconciliation، `FinalAdjustmentPrice` فقط انتخاب موقت رکوردی است و نباید به‌عنوان تعریف ابدی «فروش خالص دیدار» hard-code شود.
## Lead
- Function مستقلی مثل `get_leads` یا `search_leads` وجود ندارد؛ آن را اختراع نکن.
- API فیلدهای lead/contact را در Person/Contact نشان می‌دهد، اما یک فرمول عمومی و قطعی برای «تعداد لیدها» در مستندات عمومی تعریف نشده.
- `lead_count` تا زمانی که با پنل واقعی حساب reconcile نشده disabled بماند.
## Recovery
- اگر raw function validation failed شد، همان function را با schema برگشتی اصلاح کن.
- اگر semantic operation accuracy guard داد، به endpoint دیگری نپر تا فقط عددی تولید شود؛ دلیل عدم قطعیت را حفظ کن.
@@ -0,0 +1,26 @@
{
"id": "didar",
"version": "1.0.0",
"display_name": "دیدار CRM",
"description": "اتصال مستقیم به مشتریان، معاملات، فعالیت‌ها، کاربران و سایر اطلاعات دیدار.",
"category": "crm",
"base_url": "https://app.didar.me",
"authentication": {
"type": "api_key_query",
"parameter_name": "apikey",
"credential_field": "api_key"
},
"default_headers": {
"Content-Type": "application/json"
},
"mode": "read_only",
"health_check": {
"function": "get_users",
"body": {}
},
"aliases": {
"get_leads": ["search_persons", "search_deals"],
"search_leads": ["search_persons", "search_deals"],
"list_leads": ["search_persons", "search_deals"]
}
}
@@ -0,0 +1,48 @@
{
"schema_version": "1.0",
"provider": "didar",
"source_fields": {
"deal_amount": "Price",
"deal_final_amount": "FinalAdjustmentPrice",
"currency_id": "CurrencyId",
"search_aggregate_total": [
"Response.TotalPrice",
"Response.AllDealsTotalPrice",
"Response.PendingDealsTotalPrice",
"Response.WonDealsTotalPrice",
"Response.LostDealsTotalPrice"
]
},
"didar_product_behavior": {
"multi_currency_supported": true,
"base_currency_exists": true,
"exchange_rates_are_account_configured": true,
"reports_can_be_viewed_by_currency": true
},
"safe_reporting_policy": {
"single_currency_known_mapping": "Aggregate and show amount with the known unit/code.",
"single_currency_unknown_mapping": "Aggregate only if business semantics otherwise permit, but label the unit as CurrencyId=<id>; do not say ریال/تومان/etc.",
"multiple_currency_ids": "Never sum raw Price/FinalAdjustmentPrice values across currencies.",
"server_aggregate_with_multiple_currencies": "Do not assume the API aggregate equals the Didar dashboard base-currency conversion until a golden reconciliation proves it for the account.",
"conversion": "Only convert when the connector has a verified base-currency/exchange-rate profile sourced from a trustworthy provider surface or explicit account calibration."
},
"connection_calibration_fields": {
"base_currency_id": null,
"base_currency_code": null,
"base_currency_name": null,
"display_unit": null,
"currency_map": {},
"dashboard_aggregate_currency_semantics_verified": false
},
"amount_field_policy": {
"default_record_amount": "FinalAdjustmentPrice",
"status": "provisional_until_golden_reconciliation",
"fallback": "Price",
"rule": "Do not permanently hard-code which field equals Didar's dashboard sales amount until golden tests against the panel pass."
},
"user_facing_rules": [
"When currency semantics are unverified, say so briefly rather than inventing a unit.",
"If more than one CurrencyId is present and no verified conversion exists, report separate totals per CurrencyId.",
"A successful HTTP response is not enough to call a money report accurate."
]
}
@@ -0,0 +1,260 @@
{
"schema_version": "1.0",
"provider": "didar",
"semantic_operations": [
{
"name": "sales_summary",
"title_fa": "خلاصه فروش",
"enabled": true,
"input_schema": {
"type": "object",
"properties": {
"period": {
"type": "string"
},
"owner_ids": {
"type": "array",
"items": {
"type": "string"
}
},
"pipeline_id": {
"type": "string"
}
},
"required": [
"period"
],
"additionalProperties": false
},
"business_definition": "Won deals whose won time belongs to the requested period.",
"date_basis": "won_at",
"raw_function": "search_deals",
"query_template": {
"Criteria": {
"Status": "Won",
"Sort": 9,
"SearchFromTime": "$period.start_utc",
"SearchToTime": "$period.end_utc_exclusive",
"IsDeleted": false
},
"From": 0,
"Limit": 100
},
"preferred_server_fields": [
"Response.WonDealsCount",
"Response.WonDealsTotalPrice"
],
"accuracy": {
"must_verify_row_status": true,
"must_verify_won_time_range": true,
"requires_golden_reconciliation_for_panel_parity": true,
"do_not_count_page_list_for_total": true
}
},
{
"name": "won_deals",
"title_fa": "معاملات موفق در بازه",
"enabled": true,
"input_schema": {
"type": "object",
"properties": {
"period": {
"type": "string"
},
"owner_ids": {
"type": "array",
"items": {
"type": "string"
}
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 500
}
},
"required": [
"period"
],
"additionalProperties": false
},
"business_definition": "Deals currently marked Won whose won time is in the requested period.",
"date_basis": "won_at",
"raw_function": "search_deals",
"status": "Won",
"materialize_records": true
},
{
"name": "lost_deals",
"title_fa": "معاملات ناموفق در بازه",
"enabled": true,
"input_schema": {
"type": "object",
"properties": {
"period": {
"type": "string"
},
"owner_ids": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"period"
],
"additionalProperties": false
},
"business_definition": "Deals currently marked Lost whose loss time is in the requested period.",
"date_basis": "lost_at",
"raw_function": "search_deals",
"status": "Lost",
"accuracy": {
"server_side_loss_date_filter_verified": false,
"safe_mode": "post_filter_ChangeToLossTime"
}
},
{
"name": "deals_created",
"title_fa": "معاملات ثبت‌شده در بازه",
"enabled": true,
"input_schema": {
"type": "object",
"properties": {
"period": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"Pending",
"Won",
"Lost"
]
},
"owner_ids": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"period"
],
"additionalProperties": false
},
"business_definition": "Deals whose RegisterTime belongs to the requested period; current status must be read from Status.",
"date_basis": "created_at",
"raw_function": "search_deals",
"query_sort": 0,
"response_disclosure": "Period is based on deal registration time."
},
{
"name": "pending_pipeline_snapshot",
"title_fa": "نمای فعلی معاملات جاری",
"enabled": true,
"input_schema": {
"type": "object",
"properties": {
"owner_ids": {
"type": "array",
"items": {
"type": "string"
}
},
"pipeline_id": {
"type": "string"
},
"pipeline_stage_ids": {
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
},
"business_definition": "Current snapshot of deals with Status=Pending and IsDeleted=false.",
"date_basis": "current_snapshot",
"raw_function": "search_deals",
"status": "Pending"
},
{
"name": "sales_by_owner",
"title_fa": "فروش به تفکیک کارشناس",
"enabled": true,
"input_schema": {
"type": "object",
"properties": {
"period": {
"type": "string"
}
},
"required": [
"period"
],
"additionalProperties": false
},
"business_definition": "Won sales in period grouped by deal OwnerId and resolved via get_users.",
"depends_on": [
"sales_summary",
"get_users"
],
"group_by": "OwnerId",
"owner_resolution": "get_users",
"requires_code_interpreter": true,
"money_policy_ref": "money_rules.safe_reporting_policy"
},
{
"name": "sales_over_time",
"title_fa": "روند فروش در زمان",
"enabled": true,
"input_schema": {
"type": "object",
"properties": {
"period": {
"type": "string"
},
"grain": {
"type": "string",
"enum": [
"day",
"week",
"month"
]
}
},
"required": [
"period"
],
"additionalProperties": false
},
"business_definition": "Won sales grouped by ChangeToWonTime.",
"requires_records": true,
"group_date_field": "ChangeToWonTime",
"requires_code_interpreter": true
},
{
"name": "sales_by_product",
"title_fa": "فروش به تفکیک محصول",
"enabled": false,
"business_definition": "Sold quantity and amount by product for won deals in period.",
"reason_disabled": "Search Deals may not contain complete Items for every account/result. Enable only after a verified retrieval recipe and dashboard reconciliation are implemented."
},
{
"name": "sales_by_product_over_time",
"title_fa": "فروش محصول در زمان",
"enabled": false,
"reason_disabled": "Requires verified product-item retrieval and currency semantics."
},
{
"name": "lead_count",
"title_fa": "تعداد سرنخ",
"enabled": false,
"reason_disabled": "The public API exposes LeadType/LeadStatus/ContactType examples but does not define one authoritative provider-wide lead counting rule. Calibrate against the account UI before enabling."
}
]
}
@@ -0,0 +1,94 @@
{
"schema_version": "1.0",
"provider": "didar",
"tests": [
{
"id": "deal_status_authoritative",
"input_record": {
"Status": "Pending",
"ChangeToWonTime": "2023-02-06T14:40:29Z",
"Probability": 100
},
"expected": {
"canonical_status": "pending"
},
"assertion": "ChangeToWonTime or Probability must not override Status."
},
{
"id": "search_deals_page_count_not_total",
"response": {
"Response": {
"WonDealsCount": 651,
"PendingDealsCount": 0,
"LostDealsCount": 0,
"List": [
{
"Status": "Won"
}
]
}
},
"question": "چند معامله موفق داریم؟",
"expected_count_source": "Response.WonDealsCount",
"expected_count": 651
},
{
"id": "sales_period_uses_won_time",
"question": "فروش مهر ۱۴۰۵ چقدر بوده؟",
"expected_operation": "sales_summary",
"expected_date_basis": "won_at",
"expected_status": "Won"
},
{
"id": "generic_deals_period_uses_register_time",
"question": "معاملات مهر ۱۴۰۵ را بده",
"expected_operation": "deals_created",
"expected_date_basis": "created_at",
"required_disclosure": "مبنای بازه: تاریخ ثبت"
},
{
"id": "multi_currency_no_raw_sum",
"records": [
{
"FinalAdjustmentPrice": "100",
"CurrencyId": 1
},
{
"FinalAdjustmentPrice": "200",
"CurrencyId": 2
}
],
"expected": {
"raw_cross_currency_sum_allowed": false
}
},
{
"id": "unknown_currency_no_label_guess",
"records": [
{
"FinalAdjustmentPrice": "100",
"CurrencyId": 96107
}
],
"calibration": {
"currency_map": {}
},
"expected": {
"may_say_toman": false,
"may_say_rial": false
}
},
{
"id": "lead_function_not_invented",
"question": "تعداد لیدهای مهر را بده",
"expected": {
"must_not_call": [
"get_leads",
"search_leads",
"list_leads"
],
"semantic_operation_enabled": false
}
}
]
}
@@ -0,0 +1,112 @@
{
"schema_version": "1.0",
"provider": "didar",
"entity_semantics": {
"deal": {
"identity": {
"id": "Id",
"code": "Code",
"title": "Title"
},
"status": {
"field": "Status",
"enum_ref": "enums.authoritative.deal_status",
"authoritative": true,
"never_infer_from": [
"PipelineStageId",
"Probability",
"ChangeToWonTime",
"ChangeToLossTime",
"LabelIds"
]
},
"deletion": {
"field": "IsDeleted",
"separate_from_status": true
},
"dates": {
"created_at": "RegisterTime",
"won_at": "ChangeToWonTime",
"lost_at": "ChangeToLossTime",
"last_update_at": "LastUpdateTime",
"next_activity_at": "NextActivityDate",
"expected_close_at": "ExpectedCloseDate"
},
"money": {
"price": "Price",
"final_adjustment_price": "FinalAdjustmentPrice",
"estimated_price": "EstimatedPrice",
"currency_id": "CurrencyId",
"safe_default_amount_field": "FinalAdjustmentPrice",
"safe_default_is_provisional": true,
"reason": "Exact equivalence to Didar dashboard amount must be reconciled on a real account before declaring a permanent canonical amount."
},
"relations": {
"owner_id": "OwnerId",
"owner_object": "Owner",
"person_id": "PersonId",
"company_id": "CompanyId",
"pipeline_id": "PipelineId",
"pipeline_stage_id": "PipelineStageId",
"source_id": "SourceId",
"lost_reason_id": "LostReasonId"
}
},
"person": {
"identity": {
"id": "Id",
"display_name": "DisplayName"
},
"created_at": "RegisterTime",
"deleted": "IsDeleted",
"owner_id": "OwnerId",
"lead_fields": [
"LeadType",
"LeadStatus",
"LeadGenerationType",
"ContactType"
],
"lead_semantics_status": "not_fully_documented",
"rule": "Do not implement a provider-wide lead_count until reconciled against the Didar UI/account because public endpoint examples expose lead-related fields but do not define one authoritative counting rule."
},
"user": {
"resolver_function": "get_users",
"join_rule": "Deal.OwnerId should be resolved primarily against Response[].UserId. Keep fallback to Response[].Id only if a real-account contract test proves it is required.",
"display_field": "DisplayName"
},
"pipeline": {
"resolver_function": "get_deal_pipelines",
"pipeline_id_path": "Response[].Id",
"stage_id_path": "Response[].Stages[].Id",
"stage_title_path": "Response[].Stages[].Title"
}
},
"business_language": {
"فروش": "sales",
"فروش موفق": "sales",
"معاملات موفق": "won_deals",
"معاملات ناموفق": "lost_deals",
"معاملات جاری": "pending_pipeline_snapshot",
"معاملات ثبت شده": "deals_created",
"معاملات ایجاد شده": "deals_created"
},
"ambiguity_defaults": {
"معاملات <period>": {
"operation": "deals_created",
"date_basis": "created_at",
"response_disclosure": "Always state that the period basis is deal registration time."
},
"فروش <period>": {
"operation": "sales_summary",
"date_basis": "won_at",
"response_disclosure": "Always state that sales period is based on deal won time."
}
},
"accuracy_guards": [
"Never count statuses from only Response.List when Search Deals aggregate fields are available.",
"For current deal status, always trust Status over timestamps or stage/probability.",
"Never label a monetary amount as ریال/تومان/USD/etc. unless the currency mapping for the connection is known.",
"Never add raw amounts belonging to different CurrencyId values.",
"If a semantic report has not passed reconciliation for the current connector account, use safe-mode verification or clearly report that exact parity with Didar dashboard is not yet verified."
]
}
@@ -0,0 +1,57 @@
{
"schema_version": "1.0",
"provider": "didar",
"verified_at": "2026-10-12",
"sources": [
{
"id": "didar_api_start",
"url": "https://developers.didar.me/start-API",
"purpose": "Base URL, API key authentication, request/response conventions, official endpoint index"
},
{
"id": "didar_search_deals",
"url": "https://developers.didar.me/Search-Deal",
"purpose": "Search Deals request schema, status enum, sort values, aggregates, deal response fields"
},
{
"id": "didar_get_deal",
"url": "https://developers.didar.me/Get-Deal-By-Id",
"purpose": "Authoritative deal detail fields and evidence that ChangeToWonTime can be populated while Status is Pending"
},
{
"id": "didar_search_person",
"url": "https://developers.didar.me/Search-Person",
"purpose": "Person search schema, pagination, response fields including LeadType and ContactType"
},
{
"id": "didar_users",
"url": "https://developers.didar.me/User-List",
"purpose": "User/owner resolution"
},
{
"id": "didar_pipelines",
"url": "https://developers.didar.me/List-Deal-Pipelines",
"purpose": "Pipeline and stage resolution"
},
{
"id": "didar_sales_dashboard",
"url": "https://support.didar.me/docs/%d8%af%d8%a7%d8%b4%d8%a8%d9%88%d8%b1%d8%af-%da%af%d8%b2%d8%a7%d8%b1%d8%b4%d8%a7%d8%aa-%da%af%d8%b2%d8%a7%d8%b1%d8%b4-%da%a9%d9%84%db%8c-%d9%81%d8%b1%d9%88%d8%b4/",
"purpose": "Business meaning of sales reports: won deals, by time, user, product, product-over-time"
},
{
"id": "didar_won_date",
"url": "https://support.didar.me/docs/%da%86%da%af%d9%88%d9%86%d9%87-%d8%aa%d8%a7%d8%b1%db%8c%d8%ae-%d9%85%d9%88%d9%81%d9%82-%d8%b4%d8%af%d9%86-%d9%85%d8%b9%d8%a7%d9%85%d9%84%d9%87-%d8%b1%d8%a7-%d8%aa%d8%ba%db%8c%db%8c%d8%b1-%d8%af%d9%87/",
"purpose": "Meaning of deal won time"
},
{
"id": "didar_deal_status_filter",
"url": "https://support.didar.me/docs/%d9%81%db%8c%d9%84%d8%aa%d8%b1-%d9%85%d8%b9%d8%a7%d9%85%d9%84%d8%a7%d8%aa-%d8%a8%d8%b1-%d8%a7%d8%b3%d8%a7%d8%b3-%d9%86%d9%88%d8%b9-%d9%85%d8%b9%d8%a7%d9%85%d9%84%d9%87-%d9%85%d9%88%d9%81%d9%82/",
"purpose": "UI concepts: won, lost, pending/current, deleted"
},
{
"id": "didar_multi_currency",
"url": "https://support.didar.me/docs/%d9%88%db%8c%da%98%da%af%db%8c-%da%86%d9%86%d8%af-%d8%a7%d8%b1%d8%b2%db%8c-%d8%af%d8%b1-%d8%af%db%8c%d8%af%d8%a7%d8%b1/",
"purpose": "Base currency, exchange rate, decimal precision, report currency behavior"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
"""Redact secrets from connector observations and logs."""
from __future__ import annotations
import re
from typing import Any, Dict
_SECRET_KEYS = re.compile(
r"(apikey|api_key|authorization|set-cookie|cookie|token|password|secret)",
re.IGNORECASE,
)
_QUERY_SECRET = re.compile(
r"(?i)(apikey|api_key|token|password|secret)=([^&\s\"']+)"
)
def redact_text(value: str) -> str:
return _QUERY_SECRET.sub(r"\1=***", value)
def redact(value: Any) -> Any:
if isinstance(value, dict):
cleaned = {}
for key, item in value.items():
if _SECRET_KEYS.search(str(key)):
cleaned[key] = "***"
else:
cleaned[key] = redact(item)
return cleaned
if isinstance(value, list):
return [redact(item) for item in value]
if isinstance(value, str):
return redact_text(value)
return value
def strip_sensitive_fields(body: Dict[str, Any]) -> Dict[str, Any]:
return {
key: value
for key, value in body.items()
if not _SECRET_KEYS.search(str(key))
}
+311
View File
@@ -0,0 +1,311 @@
"""Load and cache predefined native connector catalogs."""
from __future__ import annotations
import copy
import json
import logging
from pathlib import Path
from typing import Dict, List, Optional
from urllib.parse import urlparse
from kamidso.native_connectors.errors import CatalogError, ProviderNotFound
from kamidso.native_connectors.flags import (
didar_connector_enabled,
is_production,
native_connectors_v1_enabled,
)
from kamidso.native_connectors.models import ConnectorFunction, NativeProvider
from kamidso.native_connectors.validator import (
check_input_schema,
clear_validator_cache,
)
logger = logging.getLogger(__name__)
_ALLOWED_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"}
_ALLOWED_EFFECTS = {"read", "write", "delete"}
_GUIDANCE_LIMIT = 8000
_REGISTRY: Optional["NativeConnectorRegistry"] = None
def _read_json(path: Path) -> object:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise CatalogError(f"Cannot read {path.name}: {exc}") from exc
def function_from_raw(raw: Dict[str, object]) -> ConnectorFunction:
transport = raw.get("transport") if isinstance(raw.get("transport"), dict) else {}
method = str(transport.get("method") or raw.get("method") or "").upper()
path = str(transport.get("path") or raw.get("path") or "")
response = raw.get("response") if isinstance(raw.get("response"), dict) else {}
root = str(response.get("root") or raw.get("response_root") or "Response")
records_path = response.get("records_path", raw.get("records_path"))
if records_path is not None:
records_path = str(records_path) or None
summary = response.get("summary_fields") or raw.get("summary_fields") or []
pagination = raw.get("pagination") if isinstance(raw.get("pagination"), dict) else None
if records_path is None and pagination and pagination.get("type") == "offset_limit":
records_path = f"{root}.List"
hints = raw.get("semantic_hints") or []
schema = raw.get("input_schema") if isinstance(raw.get("input_schema"), dict) else {}
return ConnectorFunction(
name=str(raw.get("name") or ""),
title=str(raw.get("title") or raw.get("name") or ""),
description=str(raw.get("description") or ""),
domain=str(raw.get("domain") or raw.get("entity") or ""),
effect=str(raw.get("effect") or ""),
enabled=bool(raw.get("enabled")),
method=method,
path=path,
input_schema=schema,
response_root=root,
records_path=records_path,
summary_fields=[str(item) for item in summary] if isinstance(summary, list) else [],
semantic_hints=[str(item) for item in hints] if isinstance(hints, list) else [],
pagination=pagination,
content_type=str(raw.get("content_type") or "application/json"),
timeout_seconds=int(raw.get("timeout_seconds") or 30),
)
def validate_provider(provider: NativeProvider) -> None:
parsed = urlparse(provider.base_url)
if parsed.scheme != "https" or not parsed.hostname:
raise CatalogError(f"Provider '{provider.id}' base_url must be https with a host.")
if provider.mode not in {"read_only", "read_write"}:
raise CatalogError(f"Provider '{provider.id}' has unknown mode '{provider.mode}'.")
if not provider.credential_field or not provider.auth_parameter:
raise CatalogError(f"Provider '{provider.id}' is missing authentication fields.")
seen = set()
for function in provider.functions:
if not function.name or function.name in seen:
raise CatalogError(
f"Provider '{provider.id}' has a duplicate or empty function name "
f"'{function.name}'."
)
seen.add(function.name)
if function.effect not in _ALLOWED_EFFECTS:
raise CatalogError(
f"Function '{function.name}' has unknown effect '{function.effect}'."
)
if function.method not in _ALLOWED_METHODS:
raise CatalogError(
f"Function '{function.name}' has invalid method '{function.method}'."
)
if (
not function.path.startswith("/")
or "://" in function.path
or function.path.startswith("//")
):
raise CatalogError(f"Function '{function.name}' path must be relative.")
check_input_schema(function.name, function.input_schema)
if provider.mode == "read_only" and function.enabled and function.effect != "read":
raise CatalogError(
f"Function '{function.name}' is enabled but is not a read."
)
if provider.health_function not in seen:
raise CatalogError(
f"Provider '{provider.id}' health check function "
f"'{provider.health_function}' is missing."
)
for alias, targets in provider.aliases.items():
for target in targets:
if target not in seen:
raise CatalogError(
f"Alias '{alias}' points at unknown function '{target}'."
)
def _merge_object_schema(base: Dict[str, object], incoming: Dict[str, object]) -> None:
"""Add accuracy-patch fields without replacing richer existing definitions."""
properties = base.get("properties")
incoming_properties = incoming.get("properties")
if not isinstance(properties, dict):
properties = {}
base["properties"] = properties
if not isinstance(incoming_properties, dict):
return
for key, spec in incoming_properties.items():
if not isinstance(spec, dict):
continue
current = properties.get(key)
if not isinstance(current, dict):
properties[key] = copy.deepcopy(spec)
continue
if current.get("type") == "object" and spec.get("type") == "object":
_merge_object_schema(current, spec)
continue
if "enum" in spec and "enum" not in current:
current["enum"] = copy.deepcopy(spec["enum"])
if "minimum" in spec and "minimum" not in current:
current["minimum"] = spec["minimum"]
if "maximum" in spec and "maximum" not in current:
current["maximum"] = spec["maximum"]
def _apply_accuracy_patch(directory: Path, functions_raw: List[object]) -> None:
path = directory / "function_accuracy_patch.json"
if not path.is_file():
return
document = _read_json(path)
if not isinstance(document, dict):
return
patches = document.get("patches")
if not isinstance(patches, list):
return
by_name = {
str(item.get("name")): item
for item in functions_raw
if isinstance(item, dict) and item.get("name")
}
for patch in patches:
if not isinstance(patch, dict):
continue
current = by_name.get(str(patch.get("name") or ""))
if not isinstance(current, dict):
continue
schema = patch.get("input_schema")
existing_schema = current.get("input_schema")
if isinstance(schema, dict) and isinstance(existing_schema, dict):
_merge_object_schema(existing_schema, schema)
hints = patch.get("semantic_hints")
if isinstance(hints, list):
existing_hints = current.get("semantic_hints")
if not isinstance(existing_hints, list):
existing_hints = []
current["semantic_hints"] = existing_hints
for hint in hints:
if hint not in existing_hints:
existing_hints.append(hint)
contract = patch.get("response_contract")
if not isinstance(contract, dict):
continue
if contract.get("records_path") and not current.get("records_path"):
current["records_path"] = str(contract["records_path"])
aggregates = contract.get("aggregates")
if isinstance(aggregates, list):
fields = current.get("summary_fields")
if not isinstance(fields, list):
fields = []
current["summary_fields"] = fields
for field in aggregates:
if field not in fields:
fields.append(field)
def load_provider(directory: Path) -> NativeProvider:
manifest_path = directory / "manifest.json"
functions_path = directory / "functions.json"
manifest = _read_json(manifest_path)
functions_raw = _read_json(functions_path)
if not isinstance(manifest, dict):
raise CatalogError(f"{directory.name} manifest must be an object.")
if not isinstance(functions_raw, list):
raise CatalogError(f"{directory.name} functions.json must be an array.")
_apply_accuracy_patch(directory, functions_raw)
auth = manifest.get("authentication") if isinstance(manifest.get("authentication"), dict) else {}
health = manifest.get("health_check") if isinstance(manifest.get("health_check"), dict) else {}
headers = manifest.get("default_headers") if isinstance(manifest.get("default_headers"), dict) else {}
aliases = manifest.get("aliases") if isinstance(manifest.get("aliases"), dict) else {}
guidance_path = directory / "guidance.md"
guidance = ""
if guidance_path.is_file():
guidance = guidance_path.read_text(encoding="utf-8").strip()
if len(guidance) > _GUIDANCE_LIMIT:
guidance = guidance[:_GUIDANCE_LIMIT]
functions = [
function_from_raw(item)
for item in functions_raw
if isinstance(item, dict)
]
if len(functions) != len(functions_raw):
raise CatalogError(f"{directory.name} functions.json contains a non-object entry.")
normalized_aliases: Dict[str, List[str]] = {}
for key, value in aliases.items():
if isinstance(value, list):
normalized_aliases[str(key)] = [str(item) for item in value]
return NativeProvider(
id=str(manifest.get("id") or directory.name),
version=str(manifest.get("version") or ""),
display_name=str(manifest.get("display_name") or manifest.get("id") or directory.name),
category=str(manifest.get("category") or ""),
base_url=str(manifest.get("base_url") or "").rstrip("/"),
auth_type=str(auth.get("type") or ""),
auth_parameter=str(auth.get("parameter_name") or ""),
credential_field=str(auth.get("credential_field") or ""),
default_headers={str(key): str(value) for key, value in headers.items()},
mode=str(manifest.get("mode") or "read_only"),
health_function=str(health.get("function") or ""),
health_body=health.get("body") if isinstance(health.get("body"), dict) else {},
aliases=normalized_aliases,
guidance=guidance,
functions=functions,
description=str(manifest.get("description") or ""),
)
class NativeConnectorRegistry:
def __init__(self, providers: Dict[str, NativeProvider]) -> None:
self._providers = dict(providers)
def contains(self, connector_type: Optional[str]) -> bool:
return bool(connector_type) and connector_type in self._providers
def get(self, connector_type: str) -> NativeProvider:
provider = self._providers.get(connector_type)
if provider is None:
raise ProviderNotFound(f"Native provider '{connector_type}' is not available.")
return provider
def provider_ids(self) -> List[str]:
return sorted(self._providers)
@classmethod
def load(cls, root: Optional[Path] = None) -> "NativeConnectorRegistry":
clear_validator_cache()
if not native_connectors_v1_enabled():
return cls({})
directory = root or Path(__file__).resolve().parent / "providers"
providers: Dict[str, NativeProvider] = {}
if not directory.is_dir():
return cls(providers)
for child in sorted(path for path in directory.iterdir() if path.is_dir()):
if not (child / "manifest.json").is_file():
continue
if child.name == "didar" and not didar_connector_enabled():
continue
try:
provider = load_provider(child)
if provider.id != child.name:
raise CatalogError(
f"Provider id '{provider.id}' does not match directory '{child.name}'."
)
validate_provider(provider)
except CatalogError as exc:
if is_production():
logger.critical(
"Disabling native provider '%s': %s", child.name, exc
)
continue
raise
providers[provider.id] = provider
return cls(providers)
def get_registry() -> NativeConnectorRegistry:
global _REGISTRY
if _REGISTRY is None:
_REGISTRY = NativeConnectorRegistry.load()
return _REGISTRY
def reset_registry() -> None:
global _REGISTRY
_REGISTRY = None
clear_validator_cache()
from kamidso.native_connectors.semantic.catalog import reset_semantic_cache
reset_semantic_cache()
+71
View File
@@ -0,0 +1,71 @@
"""Resolve attached connector ids into MCP ids and public native instances."""
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional, Tuple
from kamidso.native_connectors.executor import default_row_loader
from kamidso.native_connectors.flags import native_connectors_v1_enabled
from kamidso.native_connectors.registry import get_registry
logger = logging.getLogger(__name__)
def load_public_instance(connector_id: str) -> Optional[Dict[str, Any]]:
row = default_row_loader(connector_id)
if row is None:
return None
return {
"connector_id": row.get("connector_id"),
"connector_type": row.get("connector_type"),
"display_name": row.get("display_name") or "",
"status": row.get("status") or "",
"user_name": row.get("user_name"),
"sys_code": row.get("sys_code"),
}
def partition_connector_ids(
connector_ids: List[str],
) -> Tuple[List[str], List[Dict[str, Any]]]:
"""Split selected ids. Native ids never go to the MCP tool selector."""
if not connector_ids:
return [], []
if not native_connectors_v1_enabled():
return list(connector_ids), []
registry = get_registry()
mcp_ids: List[str] = []
native: List[Dict[str, Any]] = []
for connector_id in connector_ids:
row = load_public_instance(connector_id)
if row is None or not registry.contains(str(row.get("connector_type") or "")):
mcp_ids.append(connector_id)
continue
if str(row.get("status") or "") != "active":
logger.warning(
"Skipping inactive native connector '%s'", connector_id
)
continue
native.append(
{
"connector_id": row["connector_id"],
"connector_type": row["connector_type"],
"display_name": row["display_name"],
}
)
return mcp_ids, native
def install_native_connectors(
react_state: Dict[str, Any],
instances: List[Dict[str, Any]],
) -> None:
react_state["native_connectors"] = {
str(item["connector_id"]): {
"connector_id": item["connector_id"],
"connector_type": item["connector_type"],
"display_name": item.get("display_name") or item["connector_id"],
}
for item in instances
}
+180
View File
@@ -0,0 +1,180 @@
"""Save raw connector responses where code_interpreter can read them later."""
from __future__ import annotations
import json
import os
from typing import Any, Dict, List, Optional, Sequence
from kamidso.native_connectors.models import ConnectorFunction, NativeProvider
_PREVIEW_RECORDS = 10
_CELL_CHARS = 120
_MAX_OBS_CHARS = 20_000
def get_nested(payload: Any, path: Optional[str]) -> Any:
if not path or not isinstance(payload, dict):
return None
current: Any = payload
for part in path.split("."):
if not isinstance(current, dict) or part not in current:
return None
current = current[part]
return current
def _json_default(value: Any) -> str:
if isinstance(value, (bytes, bytearray)):
return value.decode("utf-8", errors="replace")
return str(value)
def _cell(value: Any) -> str:
text = str(value).replace("\n", " ")
if len(text) > _CELL_CHARS:
return text[:_CELL_CHARS] + "…"
return text
def materialize_connector_result(
react_state: Dict[str, Any],
response_json: Any,
*,
work_dir: Optional[str] = None,
) -> Dict[str, Any]:
"""Write the raw API JSON under ``connector_results/``.
Returns metadata. The file itself stays the untouched API payload.
"""
from kamidso.engine.app.openapi.api_v1.tools.sql_query import session_work_dir
folder_root = work_dir or session_work_dir(react_state.get("conv_id"))
out_dir = os.path.join(folder_root, "connector_results")
os.makedirs(out_dir, exist_ok=True)
seq = int(react_state.get("connector_result_seq") or 0) + 1
react_state["connector_result_seq"] = seq
name = f"c_{seq:03d}.json"
payload = json.dumps(response_json, ensure_ascii=False, default=_json_default)
path = os.path.join(out_dir, name)
with open(path, "w", encoding="utf-8") as handle:
handle.write(payload)
with open(os.path.join(out_dir, "latest.json"), "w", encoding="utf-8") as handle:
handle.write(payload)
relative = f"connector_results/{name}"
react_state["latest_connector_result"] = relative
return {
"result_id": f"connector_{seq:03d}",
"relative_path": relative,
"size_bytes": len(payload.encode("utf-8")),
}
def _preview_line(index: int, record: Any) -> str:
if isinstance(record, dict):
preferred = [
key
for key in ("Title", "Name", "FullName", "Id", "UserId")
if key in record
]
if not preferred:
preferred = list(record.keys())[:3]
bits = [f"{key}={_cell(record.get(key))}" for key in preferred[:3]]
return f"{index}. " + ", ".join(bits)
return f"{index}. {_cell(record)}"
def _pagination_lines(
function: ConnectorFunction,
body: Dict[str, Any],
payload: Any,
records: Optional[Sequence[Any]],
) -> List[str]:
pagination = function.pagination or {}
if pagination.get("type") != "offset_limit":
return []
offset_field = str(pagination.get("offset_field") or "From")
limit_field = str(pagination.get("limit_field") or "Limit")
returned = len(records) if isinstance(records, list) else 0
lines = [
f"Current page: {offset_field}={body.get(offset_field)} {limit_field}={body.get(limit_field)}",
f"Returned: {returned}",
]
total = get_nested(payload, f"{function.response_root}.TotalCount")
if isinstance(total, int):
lines.append(f"TotalCount: {total}")
offset = body.get(offset_field)
if isinstance(offset, int) and offset + returned < total:
lines.append("More data exists.")
return lines
def render_success_observation(
*,
provider: NativeProvider,
function: ConnectorFunction,
body: Dict[str, Any],
status_code: int,
payload: Any,
relative_path: str,
) -> str:
lines = [
f"{provider.display_name} / {function.name} completed successfully.",
"",
f"HTTP: {status_code}",
"",
"Summary:",
]
summaries = False
for path in function.summary_fields:
value = get_nested(payload, path)
if value is None:
continue
summaries = True
label = path.split(".")[-1]
lines.append(f"- {label}: {value}")
if not summaries:
lines.append("- (no summary fields)")
if function.records_path and function.records_path.endswith(".List") and function.summary_fields:
lines.append(
"Response.List is only the current page. "
"Use the aggregate counts above for totals."
)
records = get_nested(payload, function.records_path) if function.records_path else None
if isinstance(records, list):
lines.extend(["", f"Records in current page: {len(records)}", "", "Preview:"])
if not records:
lines.append("(empty)")
for index, record in enumerate(records[:_PREVIEW_RECORDS], start=1):
lines.append(_preview_line(index, record))
if len(records) > _PREVIEW_RECORDS:
lines.append(f"(Showing first {_PREVIEW_RECORDS} of {len(records)} records.)")
else:
root = get_nested(payload, function.response_root)
preview = json.dumps(root if root is not None else payload, ensure_ascii=False, default=_json_default)
if len(preview) > 1000:
preview = preview[:1000] + "…"
lines.extend(["", "Object preview:", preview])
page_lines = _pagination_lines(function, body, payload, records if isinstance(records, list) else None)
if page_lines:
lines.extend([""] + page_lines)
lines.extend(
[
"",
f"[Saved full result → PLOT_DIR/{relative_path}]",
"",
"Use code_interpreter only if computation, dataframe operations, joining, "
"statistics, charts or export are needed.",
]
)
text = "\n".join(lines)
if len(text) > _MAX_OBS_CHARS:
text = (
text[:_MAX_OBS_CHARS]
+ f"\n\n... [Output truncated at {_MAX_OBS_CHARS} chars. "
f"Full result at PLOT_DIR/{relative_path}]"
)
return text
Whitespace-only changes.
@@ -0,0 +1,12 @@
"""Public metadata for native connectors attached to the current turn.
Credentials are never stored on react_state. The executor decrypts them
only while performing a connector_call.
"""
from kamidso.native_connectors.resolve import (
install_native_connectors,
partition_connector_ids,
)
__all__ = ["install_native_connectors", "partition_connector_ids"]
@@ -0,0 +1 @@
"""Didar semantic accuracy layer. Same connector_call tool, no second runtime."""
@@ -0,0 +1,35 @@
"""Search Deal totals come from response aggregates, never from the current page length."""
from __future__ import annotations
from typing import Any, Optional, Tuple
from kamidso.native_connectors.result_store import get_nested
from kamidso.native_connectors.semantic.text import normalize_text
def as_int(value: Any) -> Optional[int]:
if isinstance(value, bool) or value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, str) and value.strip().isdigit():
return int(value.strip())
return None
def aggregate_count(payload: Any, source: str) -> Optional[int]:
return as_int(get_nested(payload, source))
def count_for_question(payload: Any, question: str) -> Tuple[str, Optional[int]]:
text = normalize_text(question)
if "ناموفق" in text:
source = "Response.LostDealsCount"
elif "جاری" in text:
source = "Response.PendingDealsCount"
elif "موفق" in text:
source = "Response.WonDealsCount"
else:
source = "Response.TotalCount"
return source, aggregate_count(payload, source)
@@ -0,0 +1,174 @@
"""Load Didar semantic contracts shipped beside the native provider."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
from kamidso.native_connectors.errors import CatalogError
from kamidso.native_connectors.flags import (
didar_connector_enabled,
native_connectors_v1_enabled,
)
from kamidso.native_connectors.semantic.text import normalize_text
_CACHE: Optional[Dict[str, Any]] = None
_BASIS_DEFAULTS = {
"sales_by_owner": "won_at",
"sales_over_time": "won_at",
}
_DISCLOSURE = {
"sales_summary": "مبنای بازه: تاریخ موفق‌شدن",
"won_deals": "مبنای بازه: تاریخ موفق‌شدن",
"sales_by_owner": "مبنای بازه: تاریخ موفق‌شدن",
"sales_over_time": "مبنای بازه: تاریخ موفق‌شدن",
"lost_deals": "مبنای بازه: تاریخ ناموفق‌شدن",
"deals_created": "مبنای بازه: تاریخ ثبت",
"pending_pipeline_snapshot": "مبنای گزارش: وضعیت فعلی",
}
def provider_dir() -> Path:
return Path(__file__).resolve().parents[1] / "providers" / "didar"
def reset_semantic_cache() -> None:
global _CACHE
_CACHE = None
def _read(path: Path) -> Dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise CatalogError(f"Cannot read {path.name}: {exc}") from exc
if not isinstance(data, dict):
raise CatalogError(f"{path.name} must be an object.")
return data
def load_bundle() -> Dict[str, Any]:
global _CACHE
if _CACHE is not None:
return _CACHE
directory = provider_dir()
loaded = {
"reports": _read(directory / "reports.json"),
"dates": _read(directory / "date_rules.json"),
"money": _read(directory / "money_rules.json"),
"semantics": _read(directory / "semantics.json"),
"enums": _read(directory / "enums.json"),
}
_CACHE = loaded
return loaded
def operations() -> List[Dict[str, Any]]:
raw = load_bundle()["reports"].get("semantic_operations") or []
if not isinstance(raw, list):
return []
return [item for item in raw if isinstance(item, dict) and item.get("name")]
def _didar_ready(connector_type: str) -> bool:
return (
connector_type == "didar"
and native_connectors_v1_enabled()
and didar_connector_enabled()
)
def find_operation(connector_type: str, name: str) -> Optional[Dict[str, Any]]:
if not _didar_ready(connector_type):
return None
for operation in operations():
if operation.get("name") == name:
return operation
return None
def enabled_operations(connector_type: str) -> List[Dict[str, Any]]:
if not _didar_ready(connector_type):
return []
return [operation for operation in operations() if operation.get("enabled")]
def operation_enabled(name: str) -> bool:
for operation in operations():
if operation.get("name") == name:
return bool(operation.get("enabled"))
return False
def date_basis_for(name: str) -> str:
for operation in operations():
if operation.get("name") == name and operation.get("date_basis"):
return str(operation["date_basis"])
return _BASIS_DEFAULTS.get(name, "")
def disclosure_for(name: str) -> str:
return _DISCLOSURE.get(name, "")
def verification_cap() -> int:
try:
value = load_bundle()["dates"]["didar_search_deals_strategy"]["won_at"]["fallback"][
"max_records"
]
return int(value)
except (KeyError, TypeError, ValueError):
return 5000
def _month_names() -> List[str]:
months = load_bundle()["dates"].get("jalali_months") or {}
if not isinstance(months, dict):
return []
return [str(name) for name in months]
def _relative_periods() -> List[str]:
parser = load_bundle()["dates"].get("parser_requirements") or {}
raw = parser.get("accept_relative_periods") if isinstance(parser, dict) else []
if not isinstance(raw, list):
return []
return [normalize_text(str(item)) for item in raw]
def has_period(text: str) -> bool:
normalized = normalize_text(text)
for phrase in _relative_periods():
if phrase and phrase in normalized:
return True
tokens = set(normalized.split())
return any(month in tokens for month in _month_names())
def route_question(text: str) -> Optional[str]:
"""Map a management question to a semantic operation name."""
normalized = normalize_text(text)
if "لید" in normalized or "سرنخ" in normalized:
return "lead_count"
if "محصول" in normalized:
return "sales_by_product"
if "کارشناس" in normalized or "به تفکیک مالک" in normalized:
return "sales_by_owner"
if "روند" in normalized:
return "sales_over_time"
if "جاری" in normalized:
return "pending_pipeline_snapshot"
if "ناموفق" in normalized:
return "lost_deals"
if "موفق" in normalized:
if has_period(normalized):
return "won_deals"
return None
if "فروش" in normalized:
return "sales_summary"
if "معاملات" in normalized or "معامله" in normalized:
return "deals_created"
return None
+236
View File
@@ -0,0 +1,236 @@
"""Resolve Jalali and relative business periods to a half-open UTC range."""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from zoneinfo import ZoneInfo
import jdatetime
from kamidso.native_connectors.semantic.catalog import load_bundle
from kamidso.native_connectors.semantic.text import normalize_text
_TEHRAN = ZoneInfo("Asia/Tehran")
class PeriodError(ValueError):
pass
@dataclass(frozen=True)
class ResolvedPeriod:
input_text: str
calendar: str
timezone: str
start_local: str
end_local_exclusive: str
start_utc: str
end_utc_exclusive: str
start: datetime
end: datetime
def contains(self, moment: datetime) -> bool:
return self.start <= moment < self.end
def parse_utc(value: object) -> Optional[datetime]:
if not isinstance(value, str) or not value.strip():
return None
text = value.strip().replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _months() -> dict:
raw = load_bundle()["dates"].get("jalali_months") or {}
return raw if isinstance(raw, dict) else {}
def _relatives() -> list:
parser = load_bundle()["dates"].get("parser_requirements") or {}
raw = parser.get("accept_relative_periods") if isinstance(parser, dict) else []
if not isinstance(raw, list):
return []
return [normalize_text(str(item)) for item in raw]
def _as_tehran(now: Optional[datetime]) -> datetime:
if now is None:
return datetime.now(_TEHRAN)
if now.tzinfo is None:
return now.replace(tzinfo=_TEHRAN)
return now.astimezone(_TEHRAN)
def _jalali_today(now: datetime) -> jdatetime.date:
return jdatetime.date.fromgregorian(date=now.date())
def _add_months(year: int, month: int, delta: int) -> tuple:
index = year * 12 + (month - 1) + delta
return index // 12, index % 12 + 1
def _month_bounds(year: int, month: int) -> tuple:
start = jdatetime.date(year, month, 1)
if month == 12:
end = jdatetime.date(year + 1, 1, 1)
else:
end = jdatetime.date(year, month + 1, 1)
return start, end
def _format_offset(moment: datetime) -> str:
text = moment.strftime("%z")
if len(text) == 5:
return text[:3] + ":" + text[3:]
return text
def _from_jalali(start: jdatetime.date, end: jdatetime.date, text: str) -> ResolvedPeriod:
start_greg = start.togregorian()
end_greg = end.togregorian()
start_local = datetime(start_greg.year, start_greg.month, start_greg.day, tzinfo=_TEHRAN)
end_local = datetime(end_greg.year, end_greg.month, end_greg.day, tzinfo=_TEHRAN)
start_utc = start_local.astimezone(timezone.utc)
end_utc = end_local.astimezone(timezone.utc)
return ResolvedPeriod(
input_text=text,
calendar="jalali",
timezone="Asia/Tehran",
start_local=(
f"{start.year:04d}-{start.month:02d}-{start.day:02d}T00:00:00"
f"{_format_offset(start_local)}"
),
end_local_exclusive=(
f"{end.year:04d}-{end.month:02d}-{end.day:02d}T00:00:00"
f"{_format_offset(end_local)}"
),
start_utc=start_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
end_utc_exclusive=end_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
start=start_utc,
end=end_utc,
)
def _from_tehran_datetimes(start_local: datetime, end_local: datetime, text: str) -> ResolvedPeriod:
start_j = jdatetime.date.fromgregorian(date=start_local.date())
end_j = jdatetime.date.fromgregorian(date=end_local.date())
start_utc = start_local.astimezone(timezone.utc)
end_utc = end_local.astimezone(timezone.utc)
return ResolvedPeriod(
input_text=text,
calendar="jalali",
timezone="Asia/Tehran",
start_local=(
f"{start_j.year:04d}-{start_j.month:02d}-{start_j.day:02d}"
f"T{start_local.strftime('%H:%M:%S')}{_format_offset(start_local)}"
),
end_local_exclusive=(
f"{end_j.year:04d}-{end_j.month:02d}-{end_j.day:02d}"
f"T{end_local.strftime('%H:%M:%S')}{_format_offset(end_local)}"
),
start_utc=start_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
end_utc_exclusive=end_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
start=start_utc,
end=end_utc,
)
def _relative(normalized: str, now: datetime, original: str) -> Optional[ResolvedPeriod]:
today = datetime(now.year, now.month, now.day, tzinfo=_TEHRAN)
jalali_today = _jalali_today(now)
if normalized == "امروز":
return _from_tehran_datetimes(today, today + timedelta(days=1), original)
if normalized == "دیروز":
return _from_tehran_datetimes(today - timedelta(days=1), today, original)
if normalized in {"این هفته", "هفته قبل"}:
delta = (today.weekday() - 5) % 7
this_saturday = today - timedelta(days=delta)
if normalized == "هفته قبل":
start = this_saturday - timedelta(days=7)
end = this_saturday
else:
start = this_saturday
end = this_saturday + timedelta(days=7)
return _from_tehran_datetimes(start, end, original)
if normalized == "این ماه":
start, end = _month_bounds(jalali_today.year, jalali_today.month)
return _from_jalali(start, end, original)
if normalized == "ماه قبل":
year, month = _add_months(jalali_today.year, jalali_today.month, -1)
start, end = _month_bounds(year, month)
return _from_jalali(start, end, original)
if normalized == "امسال":
return _from_jalali(
jdatetime.date(jalali_today.year, 1, 1),
jdatetime.date(jalali_today.year + 1, 1, 1),
original,
)
if normalized == "سال قبل":
return _from_jalali(
jdatetime.date(jalali_today.year - 1, 1, 1),
jdatetime.date(jalali_today.year, 1, 1),
original,
)
if normalized == "سه ماه اخیر":
start_year, start_month = _add_months(jalali_today.year, jalali_today.month, -2)
end_year, end_month = _add_months(jalali_today.year, jalali_today.month, 1)
return _from_jalali(
jdatetime.date(start_year, start_month, 1),
jdatetime.date(end_year, end_month, 1),
original,
)
if normalized == "شش ماه اخیر":
start_year, start_month = _add_months(jalali_today.year, jalali_today.month, -5)
end_year, end_month = _add_months(jalali_today.year, jalali_today.month, 1)
return _from_jalali(
jdatetime.date(start_year, start_month, 1),
jdatetime.date(end_year, end_month, 1),
original,
)
return None
def resolve_period(text: str, *, now: Optional[datetime] = None) -> ResolvedPeriod:
original = str(text or "").strip()
normalized = normalize_text(original)
if not normalized:
raise PeriodError("A period is required.")
local_now = _as_tehran(now)
known = set(_relatives())
if normalized in known:
resolved = _relative(normalized, local_now, original)
if resolved is not None:
return resolved
months = _months()
month_pattern = "|".join(sorted((str(name) for name in months), key=len, reverse=True))
if not month_pattern:
raise PeriodError(f"Could not resolve period '{original}'.")
match = re.fullmatch(
rf"(?:ماه\s+)?(?P<month>{month_pattern})(?:\s+ماه)?(?:\s+(?P<year>\d{{4}}))?",
normalized,
)
if match is None:
raise PeriodError(
f"Could not resolve period '{original}'. "
"Use a Jalali month such as مهر ۱۴۰۵ or a relative period such as این ماه."
)
month = int(months[match.group("month")])
year_text = match.group("year")
if year_text:
year = int(year_text)
if year < 1200 or year > 1600:
raise PeriodError(f"'{year_text}' is not a Jalali year.")
else:
year = _jalali_today(local_now).year
start, end = _month_bounds(year, month)
return _from_jalali(start, end, original)
@@ -0,0 +1,782 @@
"""Run one semantic operation through the existing raw connector caller."""
from __future__ import annotations
import copy
import json
import os
import time
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
import jdatetime
from jsonschema import Draft202012Validator
from kamidso.native_connectors.errors import (
AccuracyGuard,
FunctionDisabled,
ResultMaterializationError,
)
from kamidso.native_connectors.result_store import materialize_connector_result
from kamidso.native_connectors.semantic.aggregates import as_int
from kamidso.native_connectors.semantic.catalog import (
date_basis_for,
disclosure_for,
verification_cap,
)
from kamidso.native_connectors.semantic.dates import (
PeriodError,
ResolvedPeriod,
parse_utc,
resolve_period,
)
from kamidso.native_connectors.semantic.money import amount_lines, summarize_amounts
from kamidso.native_connectors.validator import _issue, example_skeleton
_TEHRAN = ZoneInfo("Asia/Tehran")
_PAGE = 100
_UNTRUSTED = {"won_at", "lost_at"}
_GUARD = (
"accuracy_guard: this period cannot be fully verified within the row-check limit. "
"Narrow the period. No exact total is reported. "
"عدد دقیق گزارش نمی‌شود؛ بازه را کوچک‌تر کنید."
)
def _validation(name: str, schema: Dict[str, Any], body: Any, connector_name: str) -> Optional[Dict[str, Any]]:
if not isinstance(body, dict):
issues = [{"path": [], "message": "Body must be a JSON object."}]
else:
validator = Draft202012Validator(schema if isinstance(schema, dict) else {"type": "object"})
issues = [
_issue(error)
for error in sorted(validator.iter_errors(body), key=lambda item: list(item.path))
]
if not issues:
return None
return {
"ok": False,
"error_type": "validation_error",
"connector": connector_name,
"function": name,
"message": "Request body does not match the semantic operation.",
"issues": issues,
"expected_schema": schema,
"example_skeleton": example_skeleton(schema if isinstance(schema, dict) else {}),
"http_sent": False,
"instruction": (
"Fix the body and retry the SAME semantic operation. "
"Pass a business body such as a period. Do not build Didar Criteria."
),
}
def _period_error(name: str, schema: Dict[str, Any], message: str, connector_name: str) -> Dict[str, Any]:
return {
"ok": False,
"error_type": "validation_error",
"connector": connector_name,
"function": name,
"message": message,
"issues": [{"path": ["period"], "message": message}],
"expected_schema": schema,
"example_skeleton": example_skeleton(schema if isinstance(schema, dict) else {}),
"http_sent": False,
"instruction": "Fix the period and retry the SAME semantic operation.",
}
def _basis(operation: Dict[str, Any]) -> str:
if operation.get("date_basis"):
return str(operation["date_basis"])
return date_basis_for(str(operation.get("name") or ""))
def _query(operation: Dict[str, Any], period: Optional[ResolvedPeriod], body: Dict[str, Any], basis: str) -> Dict[str, Any]:
template = operation.get("query_template")
query: Dict[str, Any]
if isinstance(template, dict):
query = copy.deepcopy(template)
else:
query = {"Criteria": {}, "From": 0, "Limit": _PAGE}
criteria = query.get("Criteria")
if not isinstance(criteria, dict):
criteria = {}
query["Criteria"] = criteria
if operation.get("status"):
criteria["Status"] = operation["status"]
elif basis == "won_at":
criteria["Status"] = "Won"
elif basis == "lost_at":
criteria["Status"] = "Lost"
elif basis == "current_snapshot":
criteria["Status"] = "Pending"
if operation.get("name") == "deals_created" and body.get("status"):
criteria["Status"] = body["status"]
if "query_sort" in operation:
criteria["Sort"] = operation["query_sort"]
elif basis == "won_at":
criteria.setdefault("Sort", 9)
elif basis == "created_at":
criteria.setdefault("Sort", 0)
criteria["IsDeleted"] = False
if period is not None and basis not in {"current_snapshot", "lost_at"}:
criteria["SearchFromTime"] = period.start_utc
criteria["SearchToTime"] = period.end_utc_exclusive
else:
criteria.pop("SearchFromTime", None)
criteria.pop("SearchToTime", None)
if basis == "lost_at":
criteria.pop("Sort", None)
if body.get("owner_ids"):
criteria["OwnerIds"] = list(body["owner_ids"])
if body.get("pipeline_id"):
criteria["PipelineId"] = body["pipeline_id"]
if body.get("pipeline_stage_ids"):
criteria["PipelineStageIds"] = list(body["pipeline_stage_ids"])
query["From"] = 0
query["Limit"] = _PAGE
return query
def _load_saved(react_state: Dict[str, Any], relative_path: str) -> Any:
from kamidso.engine.app.openapi.api_v1.tools.sql_query import session_work_dir
folder = session_work_dir(react_state.get("conv_id"))
path = os.path.join(folder, *str(relative_path).split("/"))
try:
with open(path, encoding="utf-8") as handle:
return json.load(handle)
except (OSError, json.JSONDecodeError) as exc:
raise ResultMaterializationError(
"The connector response could not be read back for verification.",
) from exc
def _deal_rows(payload: Any) -> List[Dict[str, Any]]:
response = payload.get("Response") if isinstance(payload, dict) else None
if isinstance(response, dict) and isinstance(response.get("List"), list):
return [item for item in response["List"] if isinstance(item, dict)]
return []
def _metric(payload: Any, key: str) -> Optional[int]:
response = payload.get("Response") if isinstance(payload, dict) else None
if isinstance(response, dict):
return as_int(response.get(key))
return None
def _deleted(record: Dict[str, Any]) -> bool:
return record.get("IsDeleted") in (True, 1, "1", "true", "True")
def _matches(
record: Dict[str, Any],
basis: str,
period: Optional[ResolvedPeriod],
status_filter: Optional[str],
) -> bool:
if _deleted(record):
return False
if basis == "current_snapshot":
return record.get("Status") == "Pending"
if basis == "created_at":
if status_filter and record.get("Status") != status_filter:
return False
if period is None:
return False
instant = parse_utc(record.get("RegisterTime"))
return instant is not None and period.contains(instant)
expected = "Won" if basis == "won_at" else "Lost"
if record.get("Status") != expected:
return False
if period is None:
return False
field = "ChangeToWonTime" if basis == "won_at" else "ChangeToLossTime"
instant = parse_utc(record.get(field))
return instant is not None and period.contains(instant)
def _calibration(row: Optional[Dict[str, Any]]) -> Dict[str, Any]:
if not isinstance(row, dict):
return {}
config = row.get("config")
if isinstance(config, str):
try:
config = json.loads(config)
except json.JSONDecodeError:
return {}
if not isinstance(config, dict):
return {}
stored = config.get("semantic_calibration")
return stored if isinstance(stored, dict) else {}
def _bucket(moment: datetime, grain: str) -> str:
local = moment.astimezone(_TEHRAN)
if grain == "week":
start = (local - timedelta(days=(local.weekday() - 5) % 7)).date()
stamp = jdatetime.date.fromgregorian(date=start)
return f"{stamp.year:04d}-{stamp.month:02d}-{stamp.day:02d}"
stamp_dt = jdatetime.datetime.fromgregorian(datetime=local.replace(tzinfo=None))
if grain == "day":
return f"{stamp_dt.year:04d}-{stamp_dt.month:02d}-{stamp_dt.day:02d}"
return f"{stamp_dt.year:04d}-{stamp_dt.month:02d}"
class SemanticExecutor:
def __init__(self, runner: Any) -> None:
self._runner = runner
async def execute(
self,
*,
operation: Dict[str, Any],
connector_id: str,
body: Optional[Dict[str, Any]],
react_state: Dict[str, Any],
display_name: str,
) -> Dict[str, Any]:
name = str(operation.get("name") or "")
if not operation.get("enabled"):
reason = str(operation.get("reason_disabled") or "This semantic operation is disabled.")
raise FunctionDisabled(f"Semantic operation '{name}' is disabled. {reason}", function=name)
schema = operation.get("input_schema")
if not isinstance(schema, dict):
schema = {"type": "object", "additionalProperties": False}
payload_body = body or {}
invalid = _validation(name, schema, payload_body, display_name)
if invalid:
return invalid
basis = _basis(operation)
period: Optional[ResolvedPeriod] = None
needs_period = basis != "current_snapshot"
if needs_period:
try:
period = resolve_period(str(payload_body.get("period") or ""))
except PeriodError as exc:
return _period_error(name, schema, str(exc), display_name)
query = _query(operation, period, payload_body, basis)
status_filter = payload_body.get("status") if name == "deals_created" else None
if not isinstance(status_filter, str):
status_filter = None
scanned = await self._scan(
query=query,
connector_id=connector_id,
react_state=react_state,
display_name=display_name,
function_name=str(operation.get("raw_function") or "search_deals"),
everything=basis in _UNTRUSTED,
)
if "rows" not in scanned:
return scanned
if basis not in _UNTRUSTED:
sample = [
row
for row in scanned["rows"]
if _matches(row, basis, period, status_filter)
]
if len(sample) != len(scanned["rows"]):
total = scanned.get("total")
if isinstance(total, int) and total > verification_cap():
raise AccuracyGuard(_GUARD, function=name)
scanned = await self._scan(
query=query,
connector_id=connector_id,
react_state=react_state,
display_name=display_name,
function_name=str(operation.get("raw_function") or "search_deals"),
everything=True,
)
if "rows" not in scanned:
return scanned
basis_trusted = False
else:
basis_trusted = True
else:
basis_trusted = False
matched = [
row
for row in scanned["rows"]
if _matches(row, basis, period, status_filter)
]
count, count_source, money_rows, money_withheld = _totals(
name=name,
basis=basis,
scanned=scanned,
matched=matched,
trusted_sample=basis_trusted,
)
server_count = _server_count(basis, scanned)
calibration = _calibration(self._runner.load_row(connector_id))
names: Dict[str, str] = {}
pipeline_title = ""
stage_titles: Dict[str, str] = {}
if name == "sales_by_owner":
names = await self._users(connector_id, react_state, display_name)
if payload_body.get("pipeline_id") or payload_body.get("pipeline_stage_ids"):
pipeline_title, stage_titles = await self._pipelines(
connector_id,
react_state,
display_name,
str(payload_body.get("pipeline_id") or ""),
list(payload_body.get("pipeline_stage_ids") or []),
)
return self._finish(
name=name,
basis=basis,
period=period,
count=count,
count_source=count_source,
money_rows=money_rows,
money_withheld=money_withheld,
server_count=server_count,
calibration=calibration,
matched=matched,
names=names,
pipeline_title=pipeline_title,
stage_titles=stage_titles,
grain=str(payload_body.get("grain") or "month"),
react_state=react_state,
display_name=display_name,
)
async def _scan(
self,
*,
query: Dict[str, Any],
connector_id: str,
react_state: Dict[str, Any],
display_name: str,
function_name: str,
everything: bool,
) -> Dict[str, Any]:
cap = verification_cap()
rows: List[Dict[str, Any]] = []
snapshot: Dict[str, Optional[int]] = {
"total": None,
"won": None,
"lost": None,
"pending": None,
}
frm = 0
while True:
page = copy.deepcopy(query)
page["From"] = frm
page["Limit"] = _PAGE
result = await self._runner.execute(
connector_id=connector_id,
function_name=function_name,
body=page,
react_state=react_state,
display_name=display_name,
)
if not result.get("ok"):
return result
payload = _load_saved(react_state, str(result.get("relative_path") or ""))
page_rows = _deal_rows(payload)
snapshot = {
"total": _metric(payload, "TotalCount"),
"won": _metric(payload, "WonDealsCount"),
"lost": _metric(payload, "LostDealsCount"),
"pending": _metric(payload, "PendingDealsCount"),
}
if everything and isinstance(snapshot["total"], int) and snapshot["total"] > cap:
raise AccuracyGuard(_GUARD, function=function_name)
rows.extend(page_rows)
if isinstance(snapshot["total"], int):
complete = not page_rows or frm + len(page_rows) >= snapshot["total"]
else:
complete = not page_rows or len(page_rows) < _PAGE
packed = {"rows": rows, "complete": complete, **snapshot}
if not everything or complete:
if everything and not rows and isinstance(snapshot["total"], int) and snapshot["total"] > 0:
raise AccuracyGuard(_GUARD, function=function_name)
if (
everything
and not rows
and snapshot["total"] is None
and snapshot["won"] is None
and snapshot["lost"] is None
):
raise AccuracyGuard(_GUARD, function=function_name)
return packed
if len(rows) >= cap:
raise AccuracyGuard(_GUARD, function=function_name)
frm += _PAGE
async def _users(
self,
connector_id: str,
react_state: Dict[str, Any],
display_name: str,
) -> Dict[str, str]:
cache = react_state.setdefault("connector_metadata_cache", {})
key = connector_id + ":users"
now = time.monotonic()
if isinstance(cache, dict):
cached = cache.get(key)
if isinstance(cached, dict) and now - float(cached.get("at") or 0) < 300:
names = cached.get("names")
if isinstance(names, dict):
return {str(item): str(value) for item, value in names.items()}
result = await self._runner.execute(
connector_id=connector_id,
function_name="get_users",
body={},
react_state=react_state,
display_name=display_name,
)
names: Dict[str, str] = {}
if result.get("ok"):
payload = _load_saved(react_state, str(result.get("relative_path") or ""))
records = payload.get("Response") if isinstance(payload, dict) else None
if isinstance(records, dict):
records = records.get("List")
if isinstance(records, list):
for record in records:
if not isinstance(record, dict):
continue
user_id = record.get("UserId")
label = record.get("DisplayName")
if user_id and isinstance(label, str) and label.strip():
names[str(user_id)] = label.strip()
if isinstance(cache, dict):
cache[key] = {"at": now, "names": names}
return names
async def _pipelines(
self,
connector_id: str,
react_state: Dict[str, Any],
display_name: str,
pipeline_id: str,
stage_ids: List[Any],
) -> tuple:
result = await self._runner.execute(
connector_id=connector_id,
function_name="get_deal_pipelines",
body={},
react_state=react_state,
display_name=display_name,
)
titles: Dict[str, str] = {}
stages: Dict[str, str] = {}
if result.get("ok"):
payload = _load_saved(react_state, str(result.get("relative_path") or ""))
records = payload.get("Response") if isinstance(payload, dict) else None
if isinstance(records, dict):
records = records.get("List")
if isinstance(records, list):
for item in records:
if not isinstance(item, dict):
continue
if item.get("Id") and item.get("Title"):
titles[str(item["Id"])] = str(item["Title"])
for stage in item.get("Stages") or []:
if isinstance(stage, dict) and stage.get("Id") and stage.get("Title"):
stages[str(stage["Id"])] = str(stage["Title"])
pipeline_title = titles.get(pipeline_id, "")
chosen = {str(item): stages.get(str(item), "") for item in stage_ids}
return pipeline_title, chosen
def _finish(
self,
*,
name: str,
basis: str,
period: Optional[ResolvedPeriod],
count: int,
count_source: str,
money_rows: List[Dict[str, Any]],
money_withheld: bool,
server_count: Optional[int],
calibration: Dict[str, Any],
matched: List[Dict[str, Any]],
names: Dict[str, str],
pipeline_title: str,
stage_titles: Dict[str, str],
grain: str,
react_state: Dict[str, Any],
display_name: str,
) -> Dict[str, Any]:
money = summarize_amounts(money_rows, calibration)
panel_parity = bool(calibration.get("panel_parity_verified"))
disagreement = (
basis in _UNTRUSTED
and server_count is not None
and server_count != count
)
mode = "safe"
if (
panel_parity
and not disagreement
and not money_withheld
and not money.mixed
and (money.currency_verified or not money.groups)
):
mode = "verified"
warnings = _warnings(
panel_parity=panel_parity,
money=money,
money_withheld=money_withheld,
disagreement=disagreement,
currency_verified=money.currency_verified,
)
groups = _owner_groups(matched, names) if name == "sales_by_owner" else []
timeline = _timeline(matched, grain) if name == "sales_over_time" else []
summary = {
"semantic_operation": name,
"accuracy": {
"mode": mode,
"date_basis": basis,
"period": None
if period is None
else {
"input": period.input_text,
"start_utc": period.start_utc,
"end_utc_exclusive": period.end_utc_exclusive,
},
"currency_verified": money.currency_verified,
"panel_parity_verified": panel_parity,
"warnings": warnings,
},
"count": count,
"count_source": count_source,
"amounts": money.groups,
"groups": groups,
"timeline": timeline,
"records": matched,
}
meta = materialize_connector_result(react_state, summary)
observation = _render(
display_name=display_name,
name=name,
mode=mode,
basis=basis,
period=period,
count=count,
count_source=count_source,
money=money,
money_withheld=money_withheld,
warnings=warnings,
groups=groups,
timeline=timeline,
pipeline_title=pipeline_title,
stage_titles=stage_titles,
relative_path=meta["relative_path"],
currency_verified=money.currency_verified,
panel_parity=panel_parity,
)
return {
"ok": True,
"http_sent": True,
"relative_path": meta["relative_path"],
"observation": observation,
}
def _totals(
*,
name: str,
basis: str,
scanned: Dict[str, Any],
matched: List[Dict[str, Any]],
trusted_sample: bool,
) -> tuple:
if basis in _UNTRUSTED or not trusted_sample:
return len(matched), "verified_rows", matched, False
if basis == "current_snapshot":
pending = scanned.get("pending")
if isinstance(pending, int):
return pending, "Response.PendingDealsCount", [], True
if scanned.get("complete"):
return len(matched), "verified_rows", [], True
raise AccuracyGuard(_GUARD, function=name)
total = scanned.get("total")
if isinstance(total, int):
complete = bool(scanned.get("complete"))
return total, "Response.TotalCount", matched if complete else [], not complete
if scanned.get("complete"):
return len(matched), "verified_rows", matched, False
raise AccuracyGuard(_GUARD, function=name)
def _server_count(basis: str, scanned: Dict[str, Any]) -> Optional[int]:
if basis == "won_at":
value = scanned.get("won")
elif basis == "lost_at":
value = scanned.get("lost")
elif basis == "current_snapshot":
value = scanned.get("pending")
else:
value = scanned.get("total")
return value if isinstance(value, int) else None
def _warnings(
*,
panel_parity: bool,
money: Any,
money_withheld: bool,
disagreement: bool,
currency_verified: bool,
) -> List[str]:
warnings: List[str] = []
if not panel_parity:
warnings.append("Exact parity with the Didar dashboard is not verified for this connection.")
if money_withheld:
warnings.append(
"Amount totals are withheld because the checked page is not the full period."
)
elif money.mixed:
warnings.append("Amounts from different CurrencyId values are not summed.")
elif money.groups and not currency_verified:
warnings.append("Currency name is not known; amounts are labeled by CurrencyId.")
if disagreement:
warnings.append(
"The server aggregate count was not used because returned rows did not match the period basis."
)
if money.groups and not money_withheld and not (panel_parity and currency_verified):
warnings.append(
"FinalAdjustmentPrice is a provisional record amount until golden reconciliation."
)
return warnings
def _owner_groups(records: List[Dict[str, Any]], names: Dict[str, str]) -> List[Dict[str, Any]]:
order: List[str] = []
buckets: Dict[str, List[Dict[str, Any]]] = {}
for record in records:
owner_id = str(record.get("OwnerId") or "")
if owner_id not in buckets:
order.append(owner_id)
buckets[owner_id] = []
buckets[owner_id].append(record)
groups = []
unresolved = 0
for owner_id in order:
label = names.get(owner_id, "")
if not label or label == owner_id:
unresolved += 1
label = f"کارشناس شناسایی‌نشده {unresolved}"
summary = summarize_amounts(buckets[owner_id], {})
groups.append(
{
"owner_id": owner_id,
"owner_name": label,
"count": len(buckets[owner_id]),
"amounts": summary.groups,
}
)
return groups
def _timeline(records: List[Dict[str, Any]], grain: str) -> List[Dict[str, Any]]:
order: List[str] = []
buckets: Dict[str, List[Dict[str, Any]]] = {}
for record in records:
instant = parse_utc(record.get("ChangeToWonTime"))
if instant is None:
continue
key = _bucket(instant, grain if grain in {"day", "week", "month"} else "month")
if key not in buckets:
order.append(key)
buckets[key] = []
buckets[key].append(record)
timeline = []
for key in order:
summary = summarize_amounts(buckets[key], {})
timeline.append(
{
"bucket": key,
"count": len(buckets[key]),
"amounts": summary.groups,
}
)
return timeline
def _render(
*,
display_name: str,
name: str,
mode: str,
basis: str,
period: Optional[ResolvedPeriod],
count: int,
count_source: str,
money: Any,
money_withheld: bool,
warnings: List[str],
groups: List[Dict[str, Any]],
timeline: List[Dict[str, Any]],
pipeline_title: str,
stage_titles: Dict[str, str],
relative_path: str,
currency_verified: bool,
panel_parity: bool,
) -> str:
lines = [
f"{display_name} / {name} completed.",
"",
"Accuracy:",
f"- mode: {mode}",
f"- date_basis: {basis}",
f"- currency_verified: {str(currency_verified).lower()}",
f"- panel_parity_verified: {str(panel_parity).lower()}",
]
disclosure = disclosure_for(name)
if disclosure:
lines.append(f"- {disclosure}")
if period is not None:
lines.append(f"- period: {period.input_text}")
lines.append(f"- period_utc: [{period.start_utc}, {period.end_utc_exclusive})")
if pipeline_title:
lines.append(f"- pipeline: {pipeline_title}")
elif stage_titles:
lines.append("- pipeline: کاریز شناسایی‌نشده")
for title in stage_titles.values():
lines.append(f"- stage: {title or 'مرحله شناسایی‌نشده'}")
lines.extend(["", f"count: {count}", f"count_source: {count_source}"])
if money_withheld:
lines.append("Amounts: withheld until the full period can be checked.")
elif name not in {"sales_by_owner", "sales_over_time"}:
lines.extend(amount_lines(money))
if groups:
lines.extend(["", "By owner:"])
for group in groups:
parts = [f"{group['owner_name']}: count {group['count']}"]
for amount in group["amounts"]:
parts.append(f"{amount['label']} { _plain_amount(amount['amount']) }")
lines.append("- " + "; ".join(parts))
if timeline:
lines.extend(["", "Over time:"])
for item in timeline:
parts = [f"{item['bucket']}: count {item['count']}"]
for amount in item["amounts"]:
parts.append(f"{amount['label']} { _plain_amount(amount['amount']) }")
lines.append("- " + "; ".join(parts))
if warnings:
lines.extend(["", "warnings:"])
for warning in warnings:
lines.append(f"- {warning}")
lines.extend(
[
"",
"Keep these accuracy warnings in the final answer.",
f"[Saved semantic result → PLOT_DIR/{relative_path}]",
"",
"Use code_interpreter only for further computation, joining, charts or export. "
"The count above is already verified for this operation.",
]
)
return "\n".join(lines)
def _plain_amount(value: float) -> str:
if float(value).is_integer():
return str(int(value))
return str(value)
@@ -0,0 +1,29 @@
"""Golden reconciliation stays unverified until a real panel comparison passes."""
from __future__ import annotations
from typing import Any, Dict
def reconciliation_verdict(document: Dict[str, Any]) -> Dict[str, Any]:
periods = document.get("periods") if isinstance(document, dict) else None
if not isinstance(periods, list) or not periods:
return {"verified": False, "panel_parity_verified": False, "result": "not_run"}
for period in periods:
if not isinstance(period, dict) or period.get("result") != "pass":
result = "not_run"
if isinstance(period, dict) and period.get("result"):
result = str(period.get("result"))
return {"verified": False, "panel_parity_verified": False, "result": result}
expected = period.get("panel_expected")
if not isinstance(expected, dict):
return {"verified": False, "panel_parity_verified": False, "result": "not_run"}
for key, value in expected.items():
if key == "sales_by_owner":
continue
if value is None:
return {"verified": False, "panel_parity_verified": False, "result": "not_run"}
actual = period.get("kamidso_actual")
if not isinstance(actual, dict) or not actual:
return {"verified": False, "panel_parity_verified": False, "result": "not_run"}
return {"verified": True, "panel_parity_verified": True, "result": "pass"}
+122
View File
@@ -0,0 +1,122 @@
"""Currency-safe amount reporting. Raw cross-currency sums are never produced."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from kamidso.native_connectors.semantic.catalog import load_bundle
@dataclass(frozen=True)
class MoneySummary:
groups: List[Dict[str, Any]] = field(default_factory=list)
mixed: bool = False
combined_total: Optional[float] = None
currency_verified: bool = False
def _amount_fields() -> Dict[str, str]:
rules = load_bundle()["money"]
sources = rules.get("source_fields") if isinstance(rules.get("source_fields"), dict) else {}
policy = rules.get("amount_field_policy") if isinstance(rules.get("amount_field_policy"), dict) else {}
return {
"final": str(policy.get("default_record_amount") or "FinalAdjustmentPrice"),
"fallback": str(policy.get("fallback") or "Price"),
"currency": str(sources.get("currency_id") or "CurrencyId"),
}
def _as_amount(value: Any) -> Optional[float]:
if isinstance(value, bool) or value is None:
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
text = value.strip().replace(",", "")
if not text:
return None
try:
return float(text)
except ValueError:
return None
return None
def _label(currency_id: str, currency_map: Dict[str, Any]) -> tuple:
mapped = currency_map.get(currency_id)
if isinstance(mapped, dict):
name = mapped.get("code") or mapped.get("name")
if name:
return str(name), True
if isinstance(mapped, str) and mapped.strip():
return mapped.strip(), True
shown = currency_id or "unknown"
return f"CurrencyId={shown}", False
def summarize_amounts(
records: List[Any],
calibration: Optional[Dict[str, Any]] = None,
) -> MoneySummary:
calibration = calibration or {}
fields = _amount_fields()
currency_map = calibration.get("currency_map")
if not isinstance(currency_map, dict):
currency_map = {}
buckets: Dict[str, float] = {}
order: List[str] = []
for record in records:
if not isinstance(record, dict):
continue
amount = _as_amount(record.get(fields["final"]))
if amount is None:
amount = _as_amount(record.get(fields["fallback"]))
if amount is None:
continue
currency = record.get(fields["currency"])
key = "" if currency is None else str(currency)
if key not in buckets:
order.append(key)
buckets[key] = 0.0
buckets[key] += amount
groups: List[Dict[str, Any]] = []
for key in order:
label, known = _label(key, currency_map)
groups.append(
{
"currency_id": key,
"label": label,
"amount": buckets[key],
"known": known,
}
)
mixed = len(groups) > 1
known_single = len(groups) == 1 and bool(groups[0]["known"])
verified = bool(calibration.get("dashboard_aggregate_currency_semantics_verified")) and known_single
combined = None if mixed or not groups else groups[0]["amount"]
return MoneySummary(
groups=groups,
mixed=mixed,
combined_total=combined,
currency_verified=verified,
)
def format_amount(value: float) -> str:
if float(value).is_integer():
return str(int(value))
return str(value)
def amount_lines(summary: MoneySummary) -> List[str]:
if not summary.groups:
return ["Amounts: none on the verified rows."]
if summary.mixed:
lines = ["Amounts by currency (not summed):"]
for group in summary.groups:
lines.append(f"- {group['label']}: {format_amount(group['amount'])}")
lines.append("These currency groups stay separate.")
return lines
group = summary.groups[0]
return [f"Amount: {group['label']} {format_amount(group['amount'])}"]
@@ -0,0 +1,20 @@
"""Deal status comes only from the Status field."""
from __future__ import annotations
from typing import Any, Optional
_CANONICAL = {
"Pending": "pending",
"Won": "won",
"Lost": "lost",
}
def canonical_status(record: Any) -> Optional[str]:
if not isinstance(record, dict):
return None
value = record.get("Status")
if isinstance(value, str) and value in _CANONICAL:
return _CANONICAL[value]
return None
@@ -0,0 +1,12 @@
"""Shared normalization for Persian business text."""
from __future__ import annotations
_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
def normalize_text(value: str) -> str:
text = value.replace("\u200c", "").replace("\u200d", "")
text = text.replace("ي", "ی").replace("ك", "ک").replace("ة", "ه")
text = text.translate(_DIGITS)
return " ".join(text.strip().split())
+68
View File
@@ -0,0 +1,68 @@
"""User-facing connector payloads. Credentials never leave this boundary."""
from __future__ import annotations
from typing import Any, Dict
def connector_test_message(result: Dict[str, Any]) -> Dict[str, Any]:
"""Map a probe into a short message safe to show in the product UI."""
if result.get("success"):
return {"success": True, "message": "اتصال برقرار شد."}
status = result.get("status_code")
error_type = str(result.get("error_type") or "")
if status == 401 or error_type == "authentication_error":
message = "کلید API معتبر نیست یا دسترسی آن غیرفعال شده است."
elif status == 429 or error_type == "rate_limit_error":
message = "محدودیت تعداد درخواست‌های API دیدار فعال شده است."
elif (isinstance(status, int) and status >= 500) or error_type == "remote_server_error":
message = "سرویس دیدار در حال حاضر پاسخ صحیح نمی‌دهد."
else:
message = "ارتباط با دیدار برقرار نشد."
payload: Dict[str, Any] = {"success": False, "message": message}
if isinstance(status, int):
payload["status_code"] = status
return payload
def public_connector(row: Any) -> Dict[str, Any]:
"""Sanitized instance. The stored API key is not a field on this object."""
config = row.config if isinstance(getattr(row, "config", None), dict) else {}
last_tested = config.get("last_tested_at")
last_ok = config.get("last_test_success")
return {
"id": row.connector_id,
"connector_id": row.connector_id,
"connector_type": row.connector_type,
"connector_kind": "native",
"kind": "native",
"display_name": row.display_name,
"status": row.status,
"has_credentials": True,
"last_tested_at": last_tested if isinstance(last_tested, str) else None,
"last_test_success": last_ok if isinstance(last_ok, bool) else None,
}
def public_provider(provider: Any) -> Dict[str, Any]:
description = str(getattr(provider, "description", "") or "").strip()
if not description:
description = (
"اتصال مستقیم به مشتریان، معاملات، فعالیت‌ها، کاربران و سایر اطلاعات دیدار."
)
return {
"id": provider.id,
"name": provider.display_name,
"description": description,
"category": provider.category or "crm",
"kind": "native",
"auth_fields": [
{
"name": provider.credential_field,
"label": "کلید API",
"type": "password",
"required": True,
}
],
}
+127
View File
@@ -0,0 +1,127 @@
"""JSON Schema checks and actionable validation payloads."""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError, ValidationError
from kamidso.native_connectors.errors import CatalogError
from kamidso.native_connectors.models import ConnectorFunction
_COMPILED: Dict[str, Draft202012Validator] = {}
def check_input_schema(function_name: str, schema: Dict[str, Any]) -> None:
if not isinstance(schema, dict) or schema.get("type") != "object":
raise CatalogError(
f"Function '{function_name}' input_schema must be a JSON object schema."
)
try:
Draft202012Validator.check_schema(schema)
except SchemaError as exc:
raise CatalogError(
f"Function '{function_name}' has an invalid input schema: {exc.message}"
) from exc
def compiled_validator(function: ConnectorFunction) -> Draft202012Validator:
cached = _COMPILED.get(function.name)
schema = function.input_schema
if cached is not None and cached.schema == schema:
return cached
validator = Draft202012Validator(schema)
_COMPILED[function.name] = validator
return validator
def clear_validator_cache() -> None:
_COMPILED.clear()
def example_skeleton(schema: Dict[str, Any]) -> Any:
if not isinstance(schema, dict):
return {}
if "default" in schema and schema.get("type") != "object":
return schema["default"]
kind = schema.get("type")
if kind == "object":
properties = schema.get("properties") or {}
required = schema.get("required") or []
skeleton: Dict[str, Any] = {}
for key in required:
skeleton[key] = example_skeleton(properties.get(key) or {"type": "object"})
return skeleton
if kind == "array":
return []
if kind == "integer":
minimum = schema.get("minimum", 0)
maximum = schema.get("maximum")
if isinstance(maximum, int) and maximum <= 100 and isinstance(minimum, int):
return maximum
return minimum if isinstance(minimum, int) else 0
if kind == "number":
minimum = schema.get("minimum", 0)
return minimum
if kind == "boolean":
return False
if kind == "string":
enum = schema.get("enum")
if isinstance(enum, list) and enum:
return enum[0]
return ""
return {}
def _issue(error: ValidationError) -> Dict[str, Any]:
path = list(error.path)
message = error.message
if error.validator == "additionalProperties" and isinstance(error.instance, dict):
schema = error.schema if isinstance(error.schema, dict) else {}
allowed = set((schema.get("properties") or {}).keys())
extras = sorted(key for key in error.instance.keys() if key not in allowed)
if extras:
message = "Unexpected fields: " + ", ".join(extras)
return {"path": path, "message": message}
def validate_body(
function: ConnectorFunction,
body: Any,
) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
"""Return ``(normalized_body, issues)``. Issues empty means the body is valid."""
if body is None:
body = {}
if isinstance(body, str):
return None, [{"path": [], "message": "Body must be a JSON object, not a string."}]
if not isinstance(body, dict):
return None, [{"path": [], "message": "Body must be a JSON object."}]
validator = compiled_validator(function)
errors = sorted(validator.iter_errors(body), key=lambda item: list(item.path))
if not errors:
return body, []
return None, [_issue(error) for error in errors]
def validation_payload(
*,
connector_name: str,
function: ConnectorFunction,
issues: List[Dict[str, Any]],
) -> Dict[str, Any]:
return {
"ok": False,
"error_type": "validation_error",
"connector": connector_name,
"function": function.name,
"message": "Request body does not match the predefined schema.",
"issues": issues,
"expected_schema": function.input_schema,
"example_skeleton": example_skeleton(function.input_schema),
"semantic_hints": list(function.semantic_hints),
"http_sent": False,
"instruction": (
"Fix the body and retry the SAME function. Do not invent another function."
),
}
+24
View File
@@ -19,6 +19,8 @@ class ResourceRegistry:
doc = await self._store.get_datasource(user.workspace_id, ref.id) doc = await self._store.get_datasource(user.workspace_id, ref.id)
elif ref.type == "knowledge": elif ref.type == "knowledge":
doc = await self._store.get_knowledge_source(user.workspace_id, ref.id) doc = await self._store.get_knowledge_source(user.workspace_id, ref.id)
elif ref.type == "connector":
doc = _authorize_connector(user, ref.id)
else: else:
raise KamidsoError(KamidsoErrorCode.RESOURCE_NOT_FOUND) raise KamidsoError(KamidsoErrorCode.RESOURCE_NOT_FOUND)
if doc is None: if doc is None:
@@ -52,3 +54,25 @@ class ResourceRegistry:
"status": "ready", "status": "ready",
} }
) )
def _authorize_connector(user: CurrentUser, connector_id: str) -> Optional[Dict[str, Any]]:
"""Public connector row. Credentials stay in the encrypted connector table."""
from kamidso.native_connectors.resolve import load_public_instance
row = load_public_instance(connector_id)
if row is None:
return None
owner = str(row.get("user_name") or "")
if owner and owner != user.id:
return None
workspace = str(row.get("sys_code") or "")
if workspace and user.workspace_id and workspace != user.workspace_id:
return None
status = str(row.get("status") or "")
return {
"id": row.get("connector_id"),
"status": status,
"name": row.get("display_name") or connector_id,
"connector_type": row.get("connector_type"),
}
+6 -1
View File
@@ -140,7 +140,12 @@ class DataLayout:
os.environ["HF_HOME"] = str(huggingface) os.environ["HF_HOME"] = str(huggingface)
os.environ["HUGGINGFACE_HUB_CACHE"] = str(huggingface / "hub") os.environ["HUGGINGFACE_HUB_CACHE"] = str(huggingface / "hub")
os.environ["TRANSFORMERS_CACHE"] = str(huggingface / "transformers") os.environ["TRANSFORMERS_CACHE"] = str(huggingface / "transformers")
os.environ["KAMIDSO_SKIP_SQLITE_FILE"] = "1" # Connector rows live in the engine metadata DB. Keep that file under
# data/meta so a process restart does not drop saved connections.
os.environ.pop("KAMIDSO_SKIP_SQLITE_FILE", None)
os.environ["DBGPT_OBSERVABILITY_SQLITE_PATH"] = str(
self.logs / "observability.db"
)
os.environ["KAMIDSO_OBSERVABILITY_JSONL"] = str( os.environ["KAMIDSO_OBSERVABILITY_JSONL"] = str(
self.logs / "observability.jsonl" self.logs / "observability.jsonl"
) )
+1
View File
@@ -51,6 +51,7 @@ dependencies = [
"seaborn", "seaborn",
"gTTS==2.3.1", "gTTS==2.3.1",
"pymysql", "pymysql",
"jdatetime==5.2.0",
"jsonschema", "jsonschema",
"python-jsonpath", "python-jsonpath",
"tokenizers>=0.14", "tokenizers>=0.14",
+1
View File
@@ -45,6 +45,7 @@ coloredlogs
seaborn seaborn
gTTS==2.3.1 gTTS==2.3.1
pymysql pymysql
jdatetime==5.2.0
jsonschema jsonschema
python-jsonpath python-jsonpath
tokenizers>=0.14 tokenizers>=0.14
+74
View File
@@ -0,0 +1,74 @@
"""Product-facing connector payloads stay free of secrets."""
from kamidso.contracts.user import CurrentUser
from kamidso.engine.serve.connector.service.service import ConnectorResponse
from kamidso.native_connectors.ux import connector_test_message, public_connector
def test_probe_messages_are_persian_and_drop_the_remote_body():
ok = connector_test_message({"success": True, "message": "Connection successful"})
assert ok == {"success": True, "message": "اتصال برقرار شد."}
denied = connector_test_message(
{
"success": False,
"status_code": 401,
"error_type": "authentication_error",
"message": "apikey=super-secret",
}
)
assert denied["success"] is False
assert denied["status_code"] == 401
assert "super-secret" not in denied["message"]
assert "کلید API" in denied["message"]
limited = connector_test_message({"success": False, "status_code": 429})
assert "محدودیت" in limited["message"]
remote = connector_test_message({"success": False, "status_code": 503})
assert "سرویس دیدار" in remote["message"]
offline = connector_test_message({"success": False, "error_type": "transport_error"})
assert offline["message"] == "ارتباط با دیدار برقرار نشد."
assert "status_code" not in offline
def test_public_connector_hides_config_secrets():
row = ConnectorResponse(
connector_id="conn_1",
connector_type="didar",
display_name="دیدار فروش",
status="active",
config={"api_key": "super-secret", "last_tested_at": "2026-10-12T09:00:00Z", "last_test_success": True},
)
public = public_connector(row)
assert public["has_credentials"] is True
assert public["connector_kind"] == "native"
assert public["last_tested_at"] == "2026-10-12T09:00:00Z"
assert "super-secret" not in str(public)
assert "api_key" not in public
assert "config" not in public
def test_authorize_connector_rejects_another_workspace(monkeypatch):
from kamidso.resources.registry import _authorize_connector
monkeypatch.setattr(
"kamidso.native_connectors.resolve.load_public_instance",
lambda connector_id: {
"connector_id": connector_id,
"connector_type": "didar",
"display_name": "دیدار فروش",
"status": "active",
"user_name": "usr_1",
"sys_code": "ws_a",
},
)
user = CurrentUser(
id="usr_1",
email="a@kamidso.test",
name="A",
role="user",
workspace_id="ws_b",
)
assert _authorize_connector(user, "conn_1") is None
+26
View File
@@ -161,3 +161,29 @@ def test_selected_skill_appends_settings_and_rule_pack_instructions():
assert "optional hint" in ctx.behavior assert "optional hint" in ctx.behavior
assert "IR-1405" in ctx.behavior assert "IR-1405" in ctx.behavior
assert "Article 38" in ctx.behavior assert "Article 38" in ctx.behavior
def test_connector_only_does_not_claim_nothing_is_attached():
ctx = KamidsoContextBuilder().build(
_user(),
ext_info={
"connector_ids": ["didar-a"],
"connector_labels": ["دیدار فروش"],
},
)
assert "No file or database is attached this turn" not in ctx.behavior
assert "connector_call" in ctx.behavior
assert "دیدار فروش" in ctx.behavior
assert "validation_error" in ctx.behavior
def test_connector_with_file_keeps_both_sources():
ctx = KamidsoContextBuilder().build(
_user(),
files=["file_1"],
ext_info={"connector_ids": ["didar-a"], "connector_labels": ["دیدار فروش"]},
)
assert "File(s) are attached this turn" in ctx.behavior
assert "connector_call" in ctx.behavior
assert "code_interpreter" in ctx.behavior
+6 -1
View File
@@ -77,6 +77,7 @@ def test_bind_process_env_sets_engine_home(tmp_path, monkeypatch):
"HUGGINGFACE_HUB_CACHE", "HUGGINGFACE_HUB_CACHE",
"TRANSFORMERS_CACHE", "TRANSFORMERS_CACHE",
"KAMIDSO_SKIP_SQLITE_FILE", "KAMIDSO_SKIP_SQLITE_FILE",
"DBGPT_OBSERVABILITY_SQLITE_PATH",
"KAMIDSO_OBSERVABILITY_JSONL", "KAMIDSO_OBSERVABILITY_JSONL",
"KAMIDSO_SKILLS_DIR", "KAMIDSO_SKILLS_DIR",
"DBGPT_SKILLS_DIR", "DBGPT_SKILLS_DIR",
@@ -94,7 +95,11 @@ def test_bind_process_env_sets_engine_home(tmp_path, monkeypatch):
assert Path(os.environ["CHROMA_PERSIST_PATH"]) == layout.vectors assert Path(os.environ["CHROMA_PERSIST_PATH"]) == layout.vectors
assert Path(os.environ["TMPDIR"]) == layout.temp assert Path(os.environ["TMPDIR"]) == layout.temp
assert Path(os.environ["HF_HOME"]) == layout.cache / "huggingface" assert Path(os.environ["HF_HOME"]) == layout.cache / "huggingface"
assert os.environ["KAMIDSO_SKIP_SQLITE_FILE"] == "1" assert "KAMIDSO_SKIP_SQLITE_FILE" not in os.environ
assert (
Path(os.environ["DBGPT_OBSERVABILITY_SQLITE_PATH"])
== layout.logs / "observability.db"
)
assert Path(engine_paths.LOGDIR) == layout.logs assert Path(engine_paths.LOGDIR) == layout.logs
assert Path(engine_paths.PILOT_PATH) == layout.runtime assert Path(engine_paths.PILOT_PATH) == layout.runtime
assert Path(os.environ["KAMIDSO_VECTORS_PATH"]) == layout.vectors assert Path(os.environ["KAMIDSO_VECTORS_PATH"]) == layout.vectors
+585
View File
@@ -0,0 +1,585 @@
"""Didar semantic accuracy: status, dates, money, and report routing."""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import httpx
import jdatetime
import pytest
from kamidso.engine.app.openapi.api_v1.tools.connector_call import make_connector_call
from kamidso.engine.core.agent.resource.connector.credential import CredentialStore
from kamidso.native_connectors.executor import NativeConnectorExecutor
from kamidso.native_connectors.prompt import render_native_prompt
from kamidso.native_connectors.registry import get_registry, reset_registry
from kamidso.native_connectors.semantic.aggregates import count_for_question
from kamidso.native_connectors.semantic.catalog import (
disclosure_for,
find_operation,
operation_enabled,
provider_dir,
route_question,
)
from kamidso.native_connectors.semantic.dates import resolve_period
from kamidso.native_connectors.semantic.golden import reconciliation_verdict
from kamidso.native_connectors.semantic.money import summarize_amounts
from kamidso.native_connectors.semantic.status import canonical_status
_TEHRAN = ZoneInfo("Asia/Tehran")
def _enable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("KAMIDSO_NATIVE_CONNECTORS_V1", "1")
monkeypatch.setenv("KAMIDSO_DIDAR_CONNECTOR", "1")
monkeypatch.delenv("KAMIDSO_ENV", raising=False)
reset_registry()
def _row(store: CredentialStore) -> dict:
salt = store.generate_salt()
return {
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
"status": "active",
"encrypted_credentials": store.encrypt({"api_key": "super-secret-key"}, salt),
"encryption_salt": salt,
"user_name": "usr_test",
"config": {},
}
def _client(handler, config=None):
store = CredentialStore()
row = _row(store)
if config is not None:
row["config"] = config
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
executor = NativeConnectorExecutor(
client=client,
credential_store=store,
row_loader=lambda connector_id: row if connector_id == "didar-a" else None,
)
return executor, client
def _content(text: str) -> str:
payload = json.loads(text)
return payload["chunks"][0]["content"]
def _state():
return {
"conv_id": "conv-semantic",
"user_name": "usr_test",
"native_connectors": {
"didar-a": {
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
}
},
}
def _won(when: str, amount: str, currency: int, owner: str = "owner-a") -> dict:
return {
"Status": "Won",
"ChangeToWonTime": when,
"FinalAdjustmentPrice": amount,
"CurrencyId": currency,
"OwnerId": owner,
"IsDeleted": False,
}
def test_contract_file_locks_status_counts_and_routing(monkeypatch: pytest.MonkeyPatch):
_enable(monkeypatch)
document = json.loads(
(provider_dir() / "semantic_contract_tests.json").read_text(encoding="utf-8")
)
for case in document["tests"]:
if case["id"] == "deal_status_authoritative":
assert canonical_status(case["input_record"]) == case["expected"]["canonical_status"]
elif case["id"] == "search_deals_page_count_not_total":
source, count = count_for_question(case["response"], case["question"])
assert source == case["expected_count_source"]
assert count == case["expected_count"]
assert count != len(case["response"]["Response"]["List"])
elif case["id"] == "sales_period_uses_won_time":
name = route_question(case["question"])
assert name == case["expected_operation"]
operation = find_operation("didar", name)
assert operation is not None
assert operation["date_basis"] == case["expected_date_basis"]
assert operation["query_template"]["Criteria"]["Status"] == case["expected_status"]
elif case["id"] == "generic_deals_period_uses_register_time":
name = route_question(case["question"])
assert name == case["expected_operation"]
assert find_operation("didar", name)["date_basis"] == case["expected_date_basis"]
assert case["required_disclosure"] in disclosure_for(name)
elif case["id"] == "multi_currency_no_raw_sum":
summary = summarize_amounts(case["records"], {})
assert summary.mixed is True
assert summary.combined_total is None
assert case["expected"]["raw_cross_currency_sum_allowed"] is False
elif case["id"] == "unknown_currency_no_label_guess":
summary = summarize_amounts(case["records"], case["calibration"])
label = summary.groups[0]["label"]
assert label == "CurrencyId=96107"
assert "تومان" not in label
assert "ریال" not in label
assert case["expected"]["may_say_toman"] is False
elif case["id"] == "lead_function_not_invented":
assert route_question(case["question"]) == "lead_count"
assert operation_enabled("lead_count") is False
enabled = {item.name for item in get_registry().get("didar").enabled_reads()}
for blocked in case["expected"]["must_not_call"]:
assert blocked not in enabled
def test_accuracy_patch_adds_sort_without_dropping_the_page_cap(monkeypatch: pytest.MonkeyPatch):
_enable(monkeypatch)
deals = get_registry().get("didar").function("search_deals")
criteria = deals.input_schema["properties"]["Criteria"]["properties"]
assert criteria["Sort"]["maximum"] == 10
assert criteria["Status"]["enum"] == ["Pending", "Won", "Lost"]
assert deals.input_schema["properties"]["Limit"]["maximum"] == 100
assert "Response.WonDealsTotalPrice" in deals.summary_fields
assert any("Sort=9" in hint for hint in deals.semantic_hints)
users = get_registry().get("didar").function("get_users")
assert users.records_path == "Response"
def test_jalali_month_is_half_open_tehran_and_handles_esfand():
mehr = resolve_period("مهر ۱۴۰۵")
same = resolve_period("مهر 1405")
assert mehr.start_utc == same.start_utc == "2026-09-22T20:30:00Z"
assert mehr.end_utc_exclusive == "2026-10-22T20:30:00Z"
assert mehr.start_local.startswith("1405-07-01T00:00:00")
assert mehr.end_local_exclusive.startswith("1405-08-01T00:00:00")
assert mehr.contains(mehr.start) is True
assert mehr.contains(mehr.end) is False
assert mehr.contains(mehr.end - timedelta(seconds=1)) is True
esfand = resolve_period("اسفند ۱۴۰۳")
assert esfand.start_local.startswith("1403-12-01")
assert esfand.end_local_exclusive.startswith("1404-01-01")
jdatetime.date(1403, 12, 30)
with pytest.raises(ValueError):
jdatetime.date(1404, 12, 30)
now = datetime(2026, 10, 11, 12, 0, tzinfo=_TEHRAN)
omitted = resolve_period("مهر", now=now)
assert omitted.start_utc == mehr.start_utc
month = resolve_period("این ماه", now=now)
today = jdatetime.date.fromgregorian(date=now.date())
assert month.start_local.startswith(f"{today.year:04d}-{today.month:02d}-01")
week = resolve_period("این هفته", now=now)
start = datetime.fromisoformat(week.start_utc.replace("Z", "+00:00")).astimezone(_TEHRAN)
assert start.weekday() == 5
assert start.hour == 0
assert week.end - week.start == timedelta(days=7)
def test_shipped_golden_template_is_not_verified():
document = json.loads(
(provider_dir() / "golden_reconciliation_template.json").read_text(encoding="utf-8")
)
verdict = reconciliation_verdict(document)
assert verdict["verified"] is False
assert verdict["panel_parity_verified"] is False
passed = reconciliation_verdict(
{
"periods": [
{
"result": "pass",
"panel_expected": {
"sales_won_count": 1,
"sales_amount": 1,
"currency_display": "IRR",
"won_count": 1,
"lost_count": 0,
"pending_count_snapshot": 0,
"sales_by_owner": [],
},
"kamidso_actual": {"sales_won_count": 1},
}
]
}
)
assert passed["verified"] is True
def test_prompt_prefers_semantic_operations(monkeypatch: pytest.MonkeyPatch):
_enable(monkeypatch)
text = render_native_prompt(
[{"connector_id": "didar-a", "connector_type": "didar", "display_name": "دیدار فروش"}]
)
assert "sales_summary" in text
assert "deals_created" in text
assert "get_leads" in text
assert "additionalProperties" not in text
assert "/api/" not in text
@pytest.mark.asyncio
async def test_sales_summary_verifies_won_time_and_refuses_cross_currency_sum(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
_enable(monkeypatch)
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
period = resolve_period("مهر ۱۴۰۵")
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(json.loads(request.content.decode()))
return httpx.Response(
200,
json={
"Response": {
"TotalCount": 2,
"WonDealsCount": 2,
"List": [
_won(period.start_utc, "100", 1),
_won(period.start_utc, "200", 2),
],
}
},
)
executor, client = _client(handler)
try:
tool = make_connector_call(_state(), executor=executor)
text = _content(await tool(name="sales_summary", body={"period": "مهر ۱۴۰۵"}))
finally:
await client.aclose()
assert len(calls) == 1
criteria = calls[0]["Criteria"]
assert criteria["Status"] == "Won"
assert criteria["Sort"] == 9
assert criteria["SearchFromTime"] == period.start_utc
assert criteria["SearchToTime"] == period.end_utc_exclusive
assert criteria["IsDeleted"] is False
assert "From" not in criteria
assert "\ncount: 2\n" in text
assert "date_basis: won_at" in text
assert "مبنای بازه: تاریخ موفق‌شدن" in text
assert "CurrencyId=1" in text
assert "CurrencyId=2" in text
assert "300" not in text
assert "تومان" not in text
assert "ریال" not in text
assert "panel_parity_verified: false" in text
assert "mode: safe" in text
@pytest.mark.asyncio
async def test_pending_timestamp_does_not_count_as_won(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
_enable(monkeypatch)
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
period = resolve_period("مهر 1405")
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"Response": {
"TotalCount": 1,
"WonDealsCount": 1,
"List": [
{
"Status": "Pending",
"ChangeToWonTime": period.start_utc,
"Probability": 100,
"FinalAdjustmentPrice": "40",
"CurrencyId": 96107,
}
],
}
},
)
executor, client = _client(handler)
try:
tool = make_connector_call(_state(), executor=executor)
text = _content(await tool(name="sales_summary", body={"period": "مهر 1405"}))
finally:
await client.aclose()
assert "\ncount: 0\n" in text
assert "\ncount: 1\n" not in text
assert "was not used" in text
assert "تومان" not in text
assert "ریال" not in text
@pytest.mark.asyncio
async def test_deals_created_uses_registration_total_not_the_page(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
_enable(monkeypatch)
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
period = resolve_period("مهر ۱۴۰۵")
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(json.loads(request.content.decode()))
return httpx.Response(
200,
json={
"Response": {
"TotalCount": 12,
"List": [
{
"Status": "Pending",
"ChangeToWonTime": period.start_utc,
"RegisterTime": period.start_utc,
"CurrencyId": 96107,
"FinalAdjustmentPrice": "10",
}
],
}
},
)
executor, client = _client(handler)
try:
tool = make_connector_call(_state(), executor=executor)
text = _content(await tool(name="deals_created", body={"period": "مهر ۱۴۰۵"}))
finally:
await client.aclose()
assert len(calls) == 1
assert calls[0]["Criteria"]["Sort"] == 0
assert "Status" not in calls[0]["Criteria"]
assert "\ncount: 12\n" in text
assert "count_source: Response.TotalCount" in text
assert "مبنای بازه: تاریخ ثبت" in text
assert "date_basis: created_at" in text
assert "Amounts: withheld" in text
@pytest.mark.asyncio
async def test_sales_summary_pages_past_the_first_hundred(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
_enable(monkeypatch)
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
period = resolve_period("مهر 1405")
offsets = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content.decode())
offsets.append(body["From"])
if body["From"] == 0:
rows = [_won(period.start_utc, "1", 1) for _ in range(100)]
else:
rows = [_won(period.start_utc, "1", 1)]
return httpx.Response(
200,
json={"Response": {"TotalCount": 101, "WonDealsCount": 101, "List": rows}},
)
executor, client = _client(handler)
try:
tool = make_connector_call(_state(), executor=executor)
text = _content(await tool(name="sales_summary", body={"period": "مهر 1405"}))
finally:
await client.aclose()
assert offsets == [0, 100]
assert "\ncount: 101\n" in text
assert "\ncount: 100\n" not in text
@pytest.mark.asyncio
async def test_over_cap_period_returns_accuracy_guard_without_a_total(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
_enable(monkeypatch)
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
return httpx.Response(
200,
json={
"Response": {
"TotalCount": 5001,
"WonDealsCount": 5001,
"List": [_won("2026-09-23T00:00:00Z", "9", 1)],
}
},
)
executor, client = _client(handler)
try:
tool = make_connector_call(_state(), executor=executor)
text = await tool(name="sales_summary", body={"period": "مهر ۱۴۰۵"})
finally:
await client.aclose()
assert calls["n"] == 1
assert "accuracy_guard" in text
assert "5001" not in text
assert "count:" not in text
@pytest.mark.asyncio
async def test_disabled_lead_count_and_malformed_body_send_no_http(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
_enable(monkeypatch)
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
return httpx.Response(200, json={"Response": []})
executor, client = _client(handler)
try:
tool = make_connector_call(_state(), executor=executor)
disabled = await tool(name="lead_count", body={"period": "مهر ۱۴۰۵"})
invalid = await tool(name="sales_summary", body={"Criteria": {"Status": "Won"}})
unknown = await tool(name="get_leads", body={})
finally:
await client.aclose()
assert calls["n"] == 0
assert "function_disabled" in disabled
assert "validation_error" in invalid
assert "http_sent" in invalid
assert "unknown_function" in unknown
assert "search_persons" in unknown
assert "search_deals" in unknown
@pytest.mark.asyncio
async def test_sales_by_owner_resolves_names_and_hides_raw_ids(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
_enable(monkeypatch)
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
period = resolve_period("مهر 1405")
owner_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/User/List"):
return httpx.Response(
200,
json={"Response": [{"UserId": owner_id, "DisplayName": "سارا"}]},
)
return httpx.Response(
200,
json={
"Response": {
"TotalCount": 1,
"WonDealsCount": 1,
"List": [_won(period.start_utc, "80", 96107, owner_id)],
}
},
)
executor, client = _client(handler)
try:
tool = make_connector_call(_state(), executor=executor)
text = _content(await tool(name="sales_by_owner", body={"period": "مهر 1405"}))
finally:
await client.aclose()
assert "سارا" in text
assert owner_id not in text
assert "CurrencyId=96107" in text
assert "تومان" not in text
assert "ریال" not in text
@pytest.mark.asyncio
async def test_calibrated_account_can_be_marked_verified(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
_enable(monkeypatch)
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
period = resolve_period("مهر 1405")
pipeline_id = "11111111-2222-3333-4444-555555555555"
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/pipeline/list/0"):
return httpx.Response(
200,
json={"Response": [{"Id": pipeline_id, "Title": "فروش سازمانی", "Stages": []}]},
)
return httpx.Response(
200,
json={
"Response": {
"TotalCount": 1,
"WonDealsCount": 1,
"List": [_won(period.start_utc, "80", 1)],
}
},
)
executor, client = _client(
handler,
{
"semantic_calibration": {
"panel_parity_verified": True,
"dashboard_aggregate_currency_semantics_verified": True,
"currency_map": {"1": {"code": "IRR"}},
}
},
)
try:
tool = make_connector_call(_state(), executor=executor)
text = _content(
await tool(
name="sales_summary",
body={"period": "مهر 1405", "pipeline_id": pipeline_id},
)
)
finally:
await client.aclose()
assert "mode: verified" in text
assert "currency_verified: true" in text
assert "panel_parity_verified: true" in text
assert "Amount: IRR 80" in text
assert "pipeline: فروش سازمانی" in text
assert pipeline_id not in text
@pytest.mark.asyncio
async def test_raw_search_tells_the_model_not_to_count_the_page(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
_enable(monkeypatch)
monkeypatch.setattr("kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path))
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"Response": {
"TotalCount": 651,
"WonDealsCount": 651,
"List": [{"Title": "Deal", "Status": "Won"}],
}
},
)
executor, client = _client(handler)
try:
result = await executor.execute(
connector_id="didar-a",
function_name="search_deals",
body={"Criteria": {}, "From": 0, "Limit": 100},
react_state={"conv_id": "conv-semantic"},
display_name="دیدار فروش",
)
finally:
await client.aclose()
assert "WonDealsCount: 651" in result["observation"]
assert "only the current page" in result["observation"]
+647
View File
@@ -0,0 +1,647 @@
"""Memory lifecycle: queues, ReAct scratch, adapter cache, DuckDB caps."""
from __future__ import annotations
import asyncio
import gc
import json
import tracemalloc
from types import SimpleNamespace
import psutil
import pytest
from kamidso.engine.app.openapi.api_v1.react_runtime import (
REACT_AGENT_MEMORY_CACHE,
REACT_CONVERSATION_LOCKS,
acquire_conversation_run,
conversation_lock_refcount,
end_react_turn,
release_conversation_run,
)
from kamidso.engine.core.agent.core.memory.gpts.base import GptsMessage
from kamidso.engine.core.agent.core.memory.gpts.gpts_memory import GptsMemory
from kamidso.engine.core.datasource import duckdb_memory as duck_mem
from kamidso.integrations.engine import events as adapter_events
from kamidso.integrations.engine.engine import KamidsoEngine
from kamidso.integrations.engine.events import (
adapter_cache_size,
adapter_run_is_active,
begin_adapter_run,
map_engine_event,
release_adapter_run,
sweep_adapter_cache,
)
def _message(
conv_id: str,
content: str,
*,
rounds: int = 0,
sender: str = "ReAct",
receiver: str = "Human",
action_report: str | None = None,
) -> GptsMessage:
return GptsMessage(
conv_id=conv_id,
sender=sender,
receiver=receiver,
role="ai" if sender != "Human" else "human",
content=content,
rounds=rounds,
app_code="react_agent",
app_name="ReAct",
action_report=action_report,
)
class _Store:
def __init__(self) -> None:
self.rows: list[GptsMessage] = []
self.deleted: list[str] = []
def append(self, message: GptsMessage) -> None:
self.rows.append(message)
def get_by_conv_id(self, conv_id: str) -> list[GptsMessage]:
return [row for row in self.rows if row.conv_id == conv_id]
def get_by_agent(self, conv_id: str, agent: str) -> list[GptsMessage]:
return [
row
for row in self.rows
if row.conv_id == conv_id and (row.sender == agent or row.receiver == agent)
]
def get_between_agents(self, conv_id: str, agent1: str, agent2: str, current_goal: str | None = None):
del conv_id, agent1, agent2, current_goal
return []
def get_last_message(self, conv_id: str) -> GptsMessage | None:
rows = self.get_by_conv_id(conv_id)
return rows[-1] if rows else None
def delete_by_conv_id(self, conv_id: str) -> None:
self.deleted.append(conv_id)
class _Plans:
def __init__(self) -> None:
self.removed: list[str] = []
def remove_by_conv_id(self, conv_id: str) -> None:
self.removed.append(conv_id)
class _LatestStore(_Store):
"""Keeps one row so a repeated-load test does not retain every payload."""
def append(self, message: GptsMessage) -> None:
self.rows = [message]
def _memory(store: _Store | None = None) -> tuple[GptsMemory, _Store, _Plans]:
store = store or _Store()
plans = _Plans()
return GptsMemory(plans_memory=plans, message_memory=store), store, plans
def _reset_runtime() -> None:
REACT_AGENT_MEMORY_CACHE.clear()
REACT_CONVERSATION_LOCKS.clear()
from kamidso.engine.app.openapi.api_v1 import react_runtime
react_runtime._LOCK_REFS.clear()
def test_vis_off_without_reader_does_not_queue_history_snapshot():
async def _run() -> None:
memory, store, plans = _memory()
conv = "conv-quiet"
async def _forbidden(*_args, **_kwargs):
raise AssertionError("full history snapshot was built")
memory.simple_message = _forbidden # type: ignore[method-assign]
memory.init(conv, enable_vis_message=False)
payload = "x" * 50_000
for index in range(6):
await memory.append_message(
conv,
_message(
conv,
payload,
rounds=index,
action_report=json.dumps({"content": f"step-{index}-" + payload[:32]}),
),
)
queue = memory.queue(conv)
assert queue is not None and queue.empty()
assert len(store.rows) == 6
assert store.deleted == []
assert plans.removed == []
memory.dispose_runtime(conv)
assert memory.queue(conv) is None
assert store.deleted == []
assert plans.removed == []
assert len(store.rows) == 6
asyncio.run(_run())
def test_vis_off_reader_receives_only_the_latest_message():
async def _run() -> None:
memory, _store, _plans = _memory()
conv = "conv-reader"
memory.init(conv, enable_vis_message=False)
memory.attach_queue_reader(conv)
for index in range(4):
await memory.append_message(
conv,
_message(
conv,
f"body-{index}",
rounds=index,
action_report=json.dumps({"content": f"view-{index}"}),
),
)
queued = []
queue = memory.queue(conv)
assert queue is not None
while not queue.empty():
queued.append(queue.get_nowait())
assert [item[0]["markdown"] for item in queued] == [f"view-{i}" for i in range(4)]
assert all(len(item) == 1 for item in queued)
memory.detach_queue_reader(conv)
await memory.complete(conv)
assert queue.get_nowait() == "[DONE]"
memory.dispose_runtime(conv)
asyncio.run(_run())
def test_vis_on_still_publishes_the_legacy_view():
async def _run() -> None:
memory, _store, _plans = _memory()
conv = "conv-vis"
async def _link(_conv_id: str) -> str:
return "VIS-SNAPSHOT"
memory.app_link_chat_message = _link # type: ignore[method-assign]
memory.init(conv, enable_vis_message=True)
await memory.append_message(conv, _message(conv, "hello", rounds=0))
assert memory.queue(conv).get_nowait() == "VIS-SNAPSHOT"
memory.clear(conv)
assert memory._executor is not None
memory.shutdown_executor()
asyncio.run(_run())
def test_clear_does_not_delete_persisted_rows_or_owned_executor():
async def _run() -> None:
memory, store, plans = _memory()
conv = "conv-clear"
memory.init(conv, enable_vis_message=False)
await memory.append_message(conv, _message(conv, "kept", rounds=0))
executor = memory._executor
memory.clear(conv)
assert store.get_by_conv_id(conv)[0].content == "kept"
assert store.deleted == []
assert plans.removed == []
assert memory._executor is executor
memory.shutdown_executor()
asyncio.run(_run())
def test_next_turn_reloads_history_after_dispose():
async def _run() -> None:
memory, store, plans = _memory()
conv = "conv-reload"
report = json.dumps({"content": "persisted-answer", "is_exe_success": True})
memory.init(conv, enable_vis_message=False)
await memory.append_message(
conv,
_message(conv, "what is revenue?", rounds=0, sender="Human", receiver="ReAct"),
)
await memory.append_message(
conv,
_message(conv, "persisted-answer", rounds=1, sender="ReAct", action_report=report),
)
memory.dispose_runtime(conv)
assert memory._executor is None
assert store.deleted == []
assert plans.removed == []
reloaded, _store, plans2 = _memory(store)
reloaded.init(conv, enable_vis_message=False)
rows = await reloaded.get_messages(conv)
assert [row.content for row in rows] == ["what is revenue?", "persisted-answer"]
history = await reloaded.get_agent_history_memory(conv, "ReAct")
assert history and history[0].content == "persisted-answer"
assert plans2.removed == []
reloaded.dispose_runtime(conv)
asyncio.run(_run())
def test_metadb_rows_survive_dispose(tmp_path):
from sqlalchemy.pool import NullPool
from kamidso.engine.core.storage.metadata.db_manager import db
from kamidso.engine.serve.agent.agents.db_gpts_memory import (
MetaDbGptsMessageMemory,
MetaDbGptsPlansMemory,
)
db_file = tmp_path / "lifecycle.db"
db.init_db(
f"sqlite:///{db_file.as_posix()}",
engine_args={"connect_args": {"check_same_thread": False}, "poolclass": NullPool},
)
db.create_all()
async def _run() -> None:
conv = "conv-metadb-lifecycle"
memory = GptsMemory(
plans_memory=MetaDbGptsPlansMemory(),
message_memory=MetaDbGptsMessageMemory(),
)
memory.init(conv, enable_vis_message=False)
await memory.append_message(conv, _message(conv, "durable", rounds=1, sender="Human"))
memory.dispose_runtime(conv)
rows = MetaDbGptsMessageMemory().get_by_conv_id(conv)
assert rows and rows[0].content == "durable"
follow = GptsMemory(
plans_memory=MetaDbGptsPlansMemory(),
message_memory=MetaDbGptsMessageMemory(),
)
follow.init(conv, enable_vis_message=False)
loaded = await follow.get_messages(conv)
assert loaded[0].content == "durable"
follow.dispose_runtime(conv)
asyncio.run(_run())
def test_same_conversation_is_serialized_and_lock_survives_a_waiter():
async def _run() -> None:
_reset_runtime()
conv = "conv-same"
order: list[str] = []
locks: list[asyncio.Lock] = []
class _Scratch:
def __init__(self) -> None:
self.disposed = False
def dispose_runtime(self, conv_id: str | None = None) -> None:
del conv_id
self.disposed = True
async def _turn(name: str, hold: float) -> None:
lock = await acquire_conversation_run(conv)
locks.append(lock)
order.append(f"enter:{name}")
try:
if conv not in REACT_AGENT_MEMORY_CACHE:
REACT_AGENT_MEMORY_CACHE[conv] = _Scratch()
await asyncio.sleep(hold)
order.append(f"exit:{name}")
finally:
await end_react_turn(conv, lock)
first = asyncio.create_task(_turn("a", 0.08))
await asyncio.sleep(0.02)
second = asyncio.create_task(_turn("b", 0.01))
await asyncio.sleep(0.02)
assert conversation_lock_refcount(conv) == 2
assert REACT_CONVERSATION_LOCKS[conv].locked()
await first
await second
assert order == ["enter:a", "exit:a", "enter:b", "exit:b"]
assert locks[0] is locks[1]
assert conv not in REACT_CONVERSATION_LOCKS
assert conv not in REACT_AGENT_MEMORY_CACHE
assert conversation_lock_refcount(conv) == 0
asyncio.run(_run())
def test_cancelled_waiter_does_not_pin_the_conversation_lock():
async def _run() -> None:
_reset_runtime()
conv = "conv-cancel-wait"
holder = await acquire_conversation_run(conv)
async def _wait() -> None:
await acquire_conversation_run(conv)
waiter = asyncio.create_task(_wait())
await asyncio.sleep(0.05)
assert conversation_lock_refcount(conv) == 2
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
assert conversation_lock_refcount(conv) == 1
assert REACT_CONVERSATION_LOCKS[conv] is holder
await release_conversation_run(conv, holder)
assert conv not in REACT_CONVERSATION_LOCKS
asyncio.run(_run())
def test_twenty_different_conversations_run_together():
async def _run() -> None:
_reset_runtime()
active = 0
peak = 0
async def _one(index: int) -> None:
nonlocal active, peak
conv = f"user-{index}"
lock = await acquire_conversation_run(conv)
active += 1
peak = max(peak, active)
await asyncio.sleep(0.05)
active -= 1
await release_conversation_run(conv, lock)
await asyncio.gather(*(_one(index) for index in range(20)))
assert peak == 20
assert REACT_CONVERSATION_LOCKS == {}
asyncio.run(_run())
def test_repeated_turns_release_scratch_and_allocated_memory_stabilizes():
async def _cycle(store: _LatestStore, index: int) -> None:
memory, _store, _plans = _memory(store)
conv = "conv-repeat"
memory.init(conv, enable_vis_message=False)
payload = ("row-" + str(index) + "-") * 4_000
for step in range(6):
await memory.append_message(
conv,
_message(
conv,
payload,
rounds=step,
action_report=json.dumps({"content": payload[:64]}),
),
)
assert memory.queue(conv).empty()
executor = memory._executor
memory.dispose_runtime(conv)
assert executor is not None and getattr(executor, "_shutdown", False)
assert memory._executor is None
assert memory.queue(conv) is None
assert conv not in memory.messages_cache
async def _run() -> None:
store = _LatestStore()
tracemalloc.start()
alloc: list[int] = []
rss: list[int] = []
process = psutil.Process()
try:
for index in range(100):
await _cycle(store, index)
if index % 10 == 9:
gc.collect()
current, _peak = tracemalloc.get_traced_memory()
alloc.append(current)
rss.append(process.memory_info().rss)
finally:
tracemalloc.stop()
growth = alloc[-1] - alloc[2]
print(
"LIFECYCLE_MEASURE",
f"alloc_bytes={alloc}",
f"rss_bytes={rss}",
f"alloc_growth={growth}",
f"rss_growth={rss[-1] - rss[2]}",
)
assert store.deleted == []
assert len(store.rows) == 1
# Warmup is the first samples. Later allocated memory must not climb
# with the number of turns. RSS may stay above the process baseline.
assert growth < 8 * 1024 * 1024
asyncio.run(_run())
def test_adapter_release_covers_done_error_and_abandoned_runs():
done_id = "run-done"
error_id = "run-error"
quiet_id = "run-quiet"
active_id = "run-active"
try:
begin_adapter_run(done_id)
mapped = map_engine_event(done_id, {"type": "step.start", "id": "s", "title": "sql"})
assert mapped and mapped[0]["type"] == "run.started"
assert adapter_run_is_active(done_id)
release_adapter_run(done_id)
assert not adapter_run_is_active(done_id)
begin_adapter_run(error_id)
try:
map_engine_event(error_id, {"type": "step.start", "id": "s", "title": "code"})
raise RuntimeError("stream failed")
except RuntimeError:
release_adapter_run(error_id)
assert not adapter_run_is_active(error_id)
map_engine_event(quiet_id, {"type": "step.start", "id": "s", "title": "sql"})
begin_adapter_run(active_id)
map_engine_event(active_id, {"type": "step.start", "id": "s", "title": "sql"})
with adapter_events._LOCK:
adapter_events._TOUCHED[quiet_id] = 0.0
adapter_events._TOUCHED[active_id] = 0.0
removed = sweep_adapter_cache(now=10_000.0)
assert quiet_id in removed
assert active_id not in removed
assert adapter_run_is_active(active_id)
finally:
release_adapter_run(done_id)
release_adapter_run(error_id)
release_adapter_run(quiet_id)
release_adapter_run(active_id)
def test_release_does_not_drop_another_active_run():
first = "run-keep"
second = "run-drop"
try:
begin_adapter_run(first)
begin_adapter_run(second)
map_engine_event(first, {"type": "step.start", "id": "a", "title": "sql"})
map_engine_event(second, {"type": "step.start", "id": "b", "title": "sql"})
release_adapter_run(second)
assert adapter_run_is_active(first)
assert not adapter_run_is_active(second)
assert adapter_cache_size() >= 1
finally:
release_adapter_run(first)
release_adapter_run(second)
def test_engine_releases_adapter_on_success_error_and_disconnect():
async def _collect(engine: KamidsoEngine, run_id: str):
run = SimpleNamespace(id=run_id)
events = []
async for event in engine.execute(
user=None, # type: ignore[arg-type]
run=run, # type: ignore[arg-type]
request=None, # type: ignore[arg-type]
materialized=[],
):
events.append(event)
return events
async def _run() -> None:
engine = KamidsoEngine.__new__(KamidsoEngine)
engine._runtime = SimpleNamespace(ready=False)
failed = await _collect(engine, "run-unavailable")
assert failed[0]["error"]["code"] == "ENGINE_UNAVAILABLE"
assert not adapter_run_is_active("run-unavailable")
engine._runtime = SimpleNamespace(ready=True)
async def _ok(**_kwargs):
yield {"type": "run.delta"}
engine._execute_ready = _ok # type: ignore[method-assign]
events = await _collect(engine, "run-ok")
assert events == [{"type": "run.delta"}]
assert not adapter_run_is_active("run-ok")
async def _boom(**_kwargs):
yield {"type": "partial"}
raise RuntimeError("execution failed")
engine._execute_ready = _boom # type: ignore[method-assign]
with pytest.raises(RuntimeError):
await _collect(engine, "run-boom")
assert not adapter_run_is_active("run-boom")
async def _slow(**_kwargs):
yield {"type": "partial"}
await asyncio.sleep(30)
engine._execute_ready = _slow # type: ignore[method-assign]
generator = engine.execute(
user=None, # type: ignore[arg-type]
run=SimpleNamespace(id="run-disconnect"), # type: ignore[arg-type]
request=None, # type: ignore[arg-type]
materialized=[],
)
first = await generator.__anext__()
assert first["type"] == "partial"
await generator.aclose()
assert not adapter_run_is_active("run-disconnect")
asyncio.run(_run())
def _limit_bytes(text: str) -> float:
number, unit = str(text).split()
scales = {
"b": 1,
"byte": 1,
"bytes": 1,
"kib": 1024,
"mib": 1024**2,
"gib": 1024**3,
"tib": 1024**4,
"kb": 1000,
"mb": 1000**2,
"gb": 1000**3,
"tb": 1000**4,
}
return float(number) * scales[unit.lower()]
def test_duckdb_invalid_limit_falls_back_to_two_gigabytes(monkeypatch):
monkeypatch.setenv("KAMIDSO_DUCKDB_MEMORY_LIMIT", "not-a-size")
monkeypatch.delenv("KAMIDSO_DUCKDB_AGGREGATE_MEMORY_LIMIT", raising=False)
assert duck_mem.configured_memory_limit() == "2GB"
assert duck_mem.configured_aggregate_limit() == "2GB"
def test_duckdb_tool_connections_share_one_aggregate_budget(monkeypatch):
monkeypatch.setenv("KAMIDSO_DUCKDB_MEMORY_LIMIT", "2GB")
monkeypatch.delenv("KAMIDSO_DUCKDB_AGGREGATE_MEMORY_LIMIT", raising=False)
duck_mem.reset_duckdb_memory_state_for_tests()
duck_mem.cap_duckdb_default_connection()
first = duck_mem.open_tool_duckdb(":memory:")
second = None
try:
alone = first.execute("SELECT current_setting('memory_limit')").fetchone()[0]
assert _limit_bytes(alone) == pytest.approx(2_000_000_000, rel=0.15)
assert len(duck_mem._LIVE) == 1
second = duck_mem.open_tool_duckdb(":memory:")
limits = [
_limit_bytes(conn.execute("SELECT current_setting('memory_limit')").fetchone()[0])
for conn in (first, second)
]
assert all(700 * 1024**2 < limit < 1.2 * 1024**3 for limit in limits)
duck_mem.release_tool_duckdb(second)
second.close()
second = None
restored = first.execute("SELECT current_setting('memory_limit')").fetchone()[0]
assert _limit_bytes(restored) == pytest.approx(2_000_000_000, rel=0.15)
finally:
duck_mem.release_tool_duckdb(first)
first.close()
if second is not None:
duck_mem.release_tool_duckdb(second)
second.close()
duck_mem.reset_duckdb_memory_state_for_tests()
def test_excel_reader_registers_and_releases_duckdb(tmp_path, monkeypatch):
monkeypatch.setenv("KAMIDSO_DUCKDB_MEMORY_LIMIT", "2GB")
monkeypatch.delenv("KAMIDSO_DUCKDB_AGGREGATE_MEMORY_LIMIT", raising=False)
duck_mem.reset_duckdb_memory_state_for_tests()
from kamidso.engine.app.scene.chat_data.chat_excel.excel_reader import ExcelReader
csv_path = tmp_path / "tiny.csv"
csv_path.write_text("city,amount\nShiraz,10\nTehran,20\n", encoding="utf-8")
monkeypatch.setattr(
"kamidso.engine.core.util.file_client.knowledge_upload_root",
lambda: str(tmp_path),
)
reader = ExcelReader("conv-excel", str(csv_path), read_type="df")
live_after_close = None
try:
limit = reader.db.execute("SELECT current_setting('memory_limit')").fetchone()[0]
assert _limit_bytes(limit) == pytest.approx(2_000_000_000, rel=0.15)
assert len(duck_mem._LIVE) == 1
rows = reader.db.execute("SELECT COUNT(*) FROM temp_table").fetchone()[0]
assert rows == 2
finally:
reader.close()
live_after_close = dict(duck_mem._LIVE)
duck_mem.reset_duckdb_memory_state_for_tests()
assert reader.db is None
assert live_after_close == {}
def test_duckdb_connector_binds_sqlalchemy_connections(tmp_path, monkeypatch):
monkeypatch.setenv("KAMIDSO_DUCKDB_MEMORY_LIMIT", "2GB")
monkeypatch.delenv("KAMIDSO_DUCKDB_AGGREGATE_MEMORY_LIMIT", raising=False)
duck_mem.reset_duckdb_memory_state_for_tests()
from kamidso.engine.ext.datasource.rdbms.conn_duckdb import DuckDbConnector
connector = DuckDbConnector.from_file_path(str(tmp_path / "analytics.duckdb"))
try:
with connector._engine.connect() as connection:
limit = connection.exec_driver_sql("SELECT current_setting('memory_limit')").scalar()
assert _limit_bytes(str(limit)) == pytest.approx(2_000_000_000, rel=0.15)
finally:
connector._engine.dispose()
duck_mem.reset_duckdb_memory_state_for_tests()
+482
View File
@@ -0,0 +1,482 @@
"""Native Didar connector: catalog, validation, HTTP boundary, and handoff."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import httpx
import pytest
from kamidso.contracts.chat import KamidsoChatRequest
from kamidso.engine.app.openapi.api_v1.tools.code_interpreter import make_code_interpreter
from kamidso.engine.app.openapi.api_v1.tools.connector_call import make_connector_call
from kamidso.engine.core.agent.resource.connector.credential import CredentialStore
from kamidso.integrations.engine.engine import connector_selection
from kamidso.native_connectors.errors import CatalogError
from kamidso.native_connectors.executor import NativeConnectorExecutor
from kamidso.native_connectors.prompt import render_native_prompt
from kamidso.native_connectors.registry import (
NativeConnectorRegistry,
get_registry,
reset_registry,
)
from kamidso.native_connectors.resolve import partition_connector_ids
from kamidso.native_connectors.result_store import materialize_connector_result
def _enable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("KAMIDSO_NATIVE_CONNECTORS_V1", "1")
monkeypatch.setenv("KAMIDSO_DIDAR_CONNECTOR", "1")
monkeypatch.delenv("KAMIDSO_ENV", raising=False)
reset_registry()
def _provider():
return get_registry().get("didar")
def test_didar_catalog_is_fixed_and_read_only(monkeypatch: pytest.MonkeyPatch):
_enable(monkeypatch)
provider = _provider()
names = [item.name for item in provider.functions]
assert len(names) == 49
assert len(set(names)) == 49
assert provider.base_url == "https://app.didar.me"
for function in provider.functions:
assert function.path.startswith("/")
assert "://" not in function.path
assert function.method in {"GET", "POST", "PUT", "PATCH", "DELETE"}
if function.enabled:
assert function.effect == "read"
deals = provider.function("search_deals")
assert deals is not None
assert deals.input_schema["required"] == ["Criteria", "From", "Limit"]
assert deals.records_path == "Response.List"
def test_invalid_catalog_fails_fast_outside_production(tmp_path, monkeypatch):
monkeypatch.setenv("KAMIDSO_NATIVE_CONNECTORS_V1", "1")
monkeypatch.delenv("KAMIDSO_ENV", raising=False)
_write_bad_provider(tmp_path)
with pytest.raises(CatalogError):
NativeConnectorRegistry.load(tmp_path)
def test_invalid_catalog_is_disabled_in_production(tmp_path, monkeypatch):
monkeypatch.setenv("KAMIDSO_NATIVE_CONNECTORS_V1", "1")
monkeypatch.setenv("KAMIDSO_ENV", "production")
_write_bad_provider(tmp_path)
registry = NativeConnectorRegistry.load(tmp_path)
assert registry.provider_ids() == []
def _write_bad_provider(root):
directory = root / "bad"
directory.mkdir()
(directory / "manifest.json").write_text(
json.dumps(
{
"id": "bad",
"version": "0",
"display_name": "Bad",
"base_url": "https://example.com",
"authentication": {
"type": "api_key_query",
"parameter_name": "apikey",
"credential_field": "api_key",
},
"mode": "read_only",
"health_check": {"function": "ping", "body": {}},
}
),
encoding="utf-8",
)
(directory / "functions.json").write_text(
json.dumps(
[
{
"name": "ping",
"description": "ping",
"entity": "ping",
"effect": "read",
"enabled": True,
"method": "GET",
"path": "/ping",
"input_schema": {"type": "object"},
},
{
"name": "ping",
"description": "duplicate",
"entity": "ping",
"effect": "read",
"enabled": True,
"method": "GET",
"path": "/ping",
"input_schema": {"type": "object"},
},
]
),
encoding="utf-8",
)
def _row(store: CredentialStore, secret: str = "super-secret-key") -> dict:
salt = store.generate_salt()
return {
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
"status": "active",
"encrypted_credentials": store.encrypt({"api_key": secret}, salt),
"encryption_salt": salt,
"user_name": "usr_test",
}
def _executor(handler, row, store: CredentialStore):
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
executor = NativeConnectorExecutor(
client=client,
credential_store=store,
row_loader=lambda connector_id: row if connector_id == row["connector_id"] else None,
)
return executor, client
def _state():
return {
"conv_id": "conv-native",
"user_name": "usr_test",
"native_connectors": {
"didar-a": {
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
},
"didar-b": {
"connector_id": "didar-b",
"connector_type": "didar",
"display_name": "دیدار شیراز",
},
},
}
@pytest.mark.asyncio
async def test_search_deals_validation_and_one_http_call(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
_enable(monkeypatch)
monkeypatch.setattr(
"kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path)
)
calls = {"n": 0}
secret = "super-secret-key"
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
assert request.url.host == "app.didar.me"
assert request.url.path == "/api/deal/search_v2"
assert request.url.params["apikey"] == secret
assert secret not in (request.content.decode() or "")
return httpx.Response(
200,
json={
"Response": {
"TotalCount": 651,
"TotalPrice": 10,
"WonDealsCount": 651,
"LostDealsCount": 0,
"PendingDealsCount": 0,
"List": [{"Title": "Deal"}],
}
},
)
store = CredentialStore()
row = _row(store, secret)
executor, client = _executor(handler, row, store)
try:
missing = await executor.execute(
connector_id="didar-a",
function_name="search_deals",
body={"From": 0, "Limit": 100},
react_state={"conv_id": "conv-native"},
display_name="دیدار فروش",
)
assert missing["error_type"] == "validation_error"
assert missing["http_sent"] is False
assert calls["n"] == 0
nested = await executor.execute(
connector_id="didar-a",
function_name="search_deals",
body={"Criteria": {"From": 0, "Limit": 50}},
react_state={"conv_id": "conv-native"},
display_name="دیدار فروش",
)
assert nested["http_sent"] is False
assert any(issue["message"].startswith("Unexpected fields") for issue in nested["issues"])
assert calls["n"] == 0
unknown = None
try:
await executor.execute(
connector_id="didar-a",
function_name="get_leads",
body={},
react_state={"conv_id": "conv-native"},
)
except Exception as exc: # noqa: BLE001
unknown = exc
assert unknown is not None
payload = unknown.to_payload()
assert payload["error_type"] == "unknown_function"
assert payload["http_sent"] is False
assert "search_persons" in payload["suggestions"]
assert "search_deals" in payload["suggestions"]
assert calls["n"] == 0
try:
await executor.execute(
connector_id="didar-a",
function_name="create_deal",
body={"Deal": {}},
react_state={"conv_id": "conv-native"},
)
except Exception as exc: # noqa: BLE001
blocked = exc
else:
blocked = None
assert blocked is not None
blocked_payload = blocked.to_payload()
assert blocked_payload["error_type"] == "operation_not_allowed"
assert blocked_payload["http_sent"] is False
assert calls["n"] == 0
ok = await executor.execute(
connector_id="didar-a",
function_name="search_deals",
body={"Criteria": {}, "From": 0, "Limit": 100},
react_state={"conv_id": "conv-native"},
display_name="دیدار فروش",
)
assert ok["ok"] is True
assert calls["n"] == 1
saved = tmp_path / "tmp" / "conv-native" / ok["relative_path"].replace("/", os.sep)
raw = json.loads(saved.read_text(encoding="utf-8"))
assert raw["Response"]["TotalCount"] == 651
assert secret not in saved.read_text(encoding="utf-8")
assert secret not in ok["observation"]
finally:
await client.aclose()
@pytest.mark.asyncio
async def test_duplicate_connector_call_does_not_repeat_http(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
_enable(monkeypatch)
monkeypatch.setattr(
"kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path)
)
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
return httpx.Response(200, json={"Response": {"List": [], "TotalCount": 0}})
store = CredentialStore()
row = _row(store)
executor, client = _executor(handler, row, store)
state = {
"conv_id": "conv-native",
"native_connectors": {
"didar-a": {
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
}
},
}
tool = make_connector_call(state, executor=executor)
body = {"Criteria": {}, "From": 0, "Limit": 1}
try:
first = await tool(name="get_users", body={})
second = await tool(name="get_users", body={})
third = await tool(name="get_users", body={})
changed = await tool(name="search_deals", body=body)
finally:
await client.aclose()
assert calls["n"] == 2
assert "Reuse previous result" in first or "Reuse previous result" in second
assert "already executed" in third
assert "super-secret-key" not in first + second + third + changed
assert "api_key" not in json.dumps(state["native_connectors"])
@pytest.mark.asyncio
async def test_validation_failure_loop_stops_repeating_the_schema(
monkeypatch: pytest.MonkeyPatch,
):
_enable(monkeypatch)
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
return httpx.Response(200, json={})
store = CredentialStore()
executor, client = _executor(handler, _row(store), store)
state = {
"conv_id": "conv-native",
"native_connectors": {
"didar-a": {
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
}
},
}
tool = make_connector_call(state, executor=executor)
try:
first = await tool(name="search_deals", body={"From": 0})
second = await tool(name="search_deals", body={"From": 0})
third = await tool(name="search_deals", body={"From": 0})
finally:
await client.aclose()
assert calls["n"] == 0
assert "expected_schema" in first
assert "expected_schema" in second
assert "failed repeatedly" in third
assert "expected_schema" not in third
@pytest.mark.asyncio
async def test_unattached_connector_is_rejected(monkeypatch: pytest.MonkeyPatch):
_enable(monkeypatch)
state = _state()
del state["native_connectors"]["didar-b"]
tool = make_connector_call(state, executor=NativeConnectorExecutor())
text = await tool(name="get_users", connection="دیدار شیراز")
assert "connector_not_attached" in text
assert "super-secret" not in text
@pytest.mark.asyncio
async def test_several_connectors_require_a_connection(monkeypatch: pytest.MonkeyPatch):
_enable(monkeypatch)
tool = make_connector_call(_state(), executor=NativeConnectorExecutor())
text = await tool(name="get_users")
assert "connector_ambiguous" in text
assert "دیدار فروش" in text
assert "دیدار شیراز" in text
def test_prompt_lists_names_without_schema_or_secrets(monkeypatch: pytest.MonkeyPatch):
_enable(monkeypatch)
text = render_native_prompt(
[
{
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
}
]
)
assert "search_deals" in text
assert "get_leads" in text
assert "additionalProperties" not in text
assert "/api/" not in text
assert "api_key" not in text
assert "apikey" not in text
def test_partition_keeps_native_ids_away_from_mcp(monkeypatch: pytest.MonkeyPatch):
_enable(monkeypatch)
rows = {
"didar-a": {
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
"status": "active",
"user_name": "usr_test",
"api_key": "must-not-leak",
},
"mcp-1": {
"connector_id": "mcp-1",
"connector_type": "github",
"display_name": "GitHub",
"status": "active",
"user_name": "usr_test",
},
}
monkeypatch.setattr(
"kamidso.native_connectors.resolve.default_row_loader",
lambda connector_id: rows.get(connector_id),
)
mcp_ids, native = partition_connector_ids(["didar-a", "mcp-1", "missing"])
assert mcp_ids == ["mcp-1", "missing"]
assert native == [
{
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
}
]
assert "api_key" not in json.dumps(native)
def test_result_file_survives_a_fresh_python_process(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
monkeypatch.setattr(
"kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path)
)
meta = materialize_connector_result(
{"conv_id": "conv-native"},
{"Response": {"TotalCount": 651, "List": [{"Title": "Deal"}]}},
)
work = tmp_path / "tmp" / "conv-native"
env = os.environ.copy()
env["PLOT_DIR"] = str(work)
script = (
"import json, os\n"
"path = os.path.join(os.environ['PLOT_DIR'], 'connector_results', 'c_001.json')\n"
"print(json.load(open(path, encoding='utf-8'))['Response']['TotalCount'])\n"
)
completed = subprocess.run(
[sys.executable, "-c", script],
env=env,
capture_output=True,
text=True,
check=False,
)
assert completed.returncode == 0, completed.stderr
assert completed.stdout.strip() == "651"
assert meta["relative_path"] == "connector_results/c_001.json"
def test_code_interpreter_mentions_connector_results_when_attached():
attached = make_code_interpreter({}, sql_enabled=False, native_connector=True)
plain = make_code_interpreter({}, sql_enabled=False, native_connector=False)
assert "connector_results" in attached._tool.description
assert "requests/httpx" in attached._tool.description
assert "connector_results" not in plain._tool.description
def test_chat_resources_map_onto_connector_ids():
request = KamidsoChatRequest.from_body(
{
"message": "چند معامله داریم؟",
"resources": [
{"id": "didar-a", "type": "connector", "name": "دیدار فروش"},
{"id": "file-1", "type": "file", "name": "budget.xlsx"},
],
}
)
selected = connector_selection(request.resources)
assert selected == {
"connector_ids": ["didar-a"],
"connector_labels": ["دیدار فروش"],
}
+6 -1
View File
@@ -5,7 +5,12 @@ from collections.abc import AsyncIterator
import pytest import pytest
from kamidso.api.sse import SSE_PING, with_sse_keepalive from kamidso.api.sse import SSE_HEADERS, SSE_PING, with_sse_keepalive
def test_sse_headers_do_not_ask_proxies_to_reuse_the_socket():
assert "Connection" not in SSE_HEADERS
assert SSE_HEADERS["X-Accel-Buffering"] == "no"
@pytest.mark.asyncio @pytest.mark.asyncio