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>
483 lines
16 KiB
Python
483 lines
16 KiB
Python
"""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": ["دیدار فروش"],
|
|
}
|