From 587dd7489e9ec7feea10f3de88ea65d2619f110f Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 17 Mar 2026 23:23:40 +0100 Subject: [PATCH] fixed trustee rma connector --- .../connectors/accountingConnectorAbacus.py | 56 ++++++++++--- .../connectors/accountingConnectorBexio.py | 45 +++++++++-- .../connectors/accountingConnectorRma.py | 31 +++++-- .../services/serviceAgent/agentLoop.py | 80 +++++++++++++++++-- 4 files changed, 181 insertions(+), 31 deletions(-) diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py index 193c5bf6..b8d6a03d 100644 --- a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py +++ b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py @@ -40,15 +40,15 @@ class AccountingConnectorAbacus(BaseAccountingConnector): def getRequiredConfigFields(self) -> List[ConnectorConfigField]: return [ ConnectorConfigField( - key="abacusHost", - label={"en": "Abacus Host URL", "de": "Abacus Host-URL", "fr": "URL Hôte Abacus"}, + key="apiBaseUrl", + label={"en": "API Base URL", "de": "API Base URL", "fr": "URL de base API"}, fieldType="text", secret=False, - placeholder="e.g. abacus.meinefirma.ch", + placeholder="e.g. https://abacus.meinefirma.ch/api/entity/v1/", ), ConnectorConfigField( - key="mandant", - label={"en": "Mandant Number", "de": "Mandantennummer", "fr": "Numéro de mandant"}, + key="clientName", + label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"}, fieldType="text", secret=False, placeholder="e.g. 7777", @@ -68,22 +68,37 @@ class AccountingConnectorAbacus(BaseAccountingConnector): ] def _buildBaseUrl(self, config: Dict[str, Any]) -> str: - host = config["abacusHost"].rstrip("/") - if not host.startswith("http"): - host = f"https://{host}" - return host + apiBaseUrl = str(config.get("apiBaseUrl") or "").strip() + if not apiBaseUrl: + raise ValueError("Missing required config: apiBaseUrl") + if not apiBaseUrl.startswith("http"): + apiBaseUrl = f"https://{apiBaseUrl}" + return apiBaseUrl.rstrip("/") + + def _buildAuthBaseUrl(self, config: Dict[str, Any]) -> str: + apiBaseUrl = str(config.get("apiBaseUrl") or "").strip() + if not apiBaseUrl: + raise ValueError("Missing required config: apiBaseUrl") + if not apiBaseUrl.startswith("http"): + apiBaseUrl = f"https://{apiBaseUrl}" + apiBaseUrl = apiBaseUrl.rstrip("/") + if "/api/entity/v1" in apiBaseUrl: + return apiBaseUrl.split("/api/entity/v1", 1)[0] + if "/api/" in apiBaseUrl: + return apiBaseUrl.split("/api/", 1)[0] + return apiBaseUrl async def _getAccessToken(self, config: Dict[str, Any]) -> Optional[str]: """Obtain an OAuth access token using client_credentials grant. Tokens are cached and refreshed when expired (default 600s). """ - cacheKey = f"{config.get('abacusHost')}_{config.get('clientId')}" + cacheKey = f"{config.get('apiBaseUrl')}_{config.get('clientName')}_{config.get('clientId')}" cached = self._tokenCache.get(cacheKey) if cached and cached.get("expiresAt", 0) > time.time() + 30: return cached["accessToken"] - baseUrl = self._buildBaseUrl(config) + baseUrl = self._buildAuthBaseUrl(config) try: async with aiohttp.ClientSession() as session: @@ -120,8 +135,10 @@ class AccountingConnectorAbacus(BaseAccountingConnector): def _buildEntityUrl(self, config: Dict[str, Any], entity: str) -> str: baseUrl = self._buildBaseUrl(config) - mandant = config["mandant"] - return f"{baseUrl}/api/entity/v1/{mandant}/{entity}" + clientName = config.get("clientName") + if not clientName: + raise ValueError("Missing required config: clientName") + return f"{baseUrl}/{clientName}/{entity}" async def _buildAuthHeaders(self, config: Dict[str, Any]) -> Optional[Dict[str, str]]: token = await self._getAccessToken(config) @@ -130,6 +147,19 @@ class AccountingConnectorAbacus(BaseAccountingConnector): return {"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"} async def testConnection(self, config: Dict[str, Any]) -> SyncResult: + apiBaseUrl = str(config.get("apiBaseUrl") or "") + clientName = str(config.get("clientName") or "") + clientId = str(config.get("clientId") or "") + clientSecret = str(config.get("clientSecret") or "") + if not apiBaseUrl or not clientName or not clientId or not clientSecret: + return SyncResult( + success=False, + errorMessage=( + f"Missing credentials: apiBaseUrl={bool(apiBaseUrl)}, " + f"clientName={bool(clientName)}, clientId={bool(clientId)}, " + f"clientSecret={bool(clientSecret)}" + ), + ) headers = await self._buildAuthHeaders(config) if not headers: return SyncResult(success=False, errorMessage="Failed to obtain access token") diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py index ec60d761..a5487a82 100644 --- a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py +++ b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py @@ -24,7 +24,7 @@ from ..accountingConnectorBase import ( logger = logging.getLogger(__name__) -_BASE_URL = "https://api.bexio.com" +_DEFAULT_API_BASE_URL = "https://api.bexio.com/" class AccountingConnectorBexio(BaseAccountingConnector): @@ -40,6 +40,20 @@ class AccountingConnectorBexio(BaseAccountingConnector): def getRequiredConfigFields(self) -> List[ConnectorConfigField]: return [ + ConnectorConfigField( + key="apiBaseUrl", + label={"en": "API Base URL", "de": "API Base URL", "fr": "URL de base API"}, + fieldType="text", + secret=False, + placeholder="https://api.bexio.com/", + ), + ConnectorConfigField( + key="clientName", + label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"}, + fieldType="text", + secret=False, + placeholder="e.g. poweronag", + ), ConnectorConfigField( key="accessToken", label={"en": "Personal Access Token", "de": "Persönlicher Zugriffstoken", "fr": "Jeton d'accès personnel"}, @@ -49,6 +63,14 @@ class AccountingConnectorBexio(BaseAccountingConnector): ), ] + def _buildUrl(self, config: Dict[str, Any], resource: str) -> str: + apiBaseUrl = str(config.get("apiBaseUrl") or "").strip() + if not apiBaseUrl: + raise ValueError("Missing required config: apiBaseUrl") + apiBaseUrl = apiBaseUrl.rstrip("/") + resourcePath = resource.lstrip("/") + return f"{apiBaseUrl}/{resourcePath}" + def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]: return { "Authorization": f"Bearer {config['accessToken']}", @@ -57,9 +79,20 @@ class AccountingConnectorBexio(BaseAccountingConnector): } async def testConnection(self, config: Dict[str, Any]) -> SyncResult: + apiBaseUrl = str(config.get("apiBaseUrl") or "") + clientName = str(config.get("clientName") or "") + accessToken = str(config.get("accessToken") or "") + if not apiBaseUrl or not clientName or not accessToken: + return SyncResult( + success=False, + errorMessage=( + f"Missing credentials: apiBaseUrl={bool(apiBaseUrl)}, " + f"clientName={bool(clientName)}, accessToken={bool(accessToken)}" + ), + ) try: async with aiohttp.ClientSession() as session: - async with session.get(f"{_BASE_URL}/3.0/users/me", headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp: + async with session.get(self._buildUrl(config, "3.0/users/me"), headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp: if resp.status == 200: return SyncResult(success=True) body = await resp.text() @@ -75,7 +108,7 @@ class AccountingConnectorBexio(BaseAccountingConnector): try: async with aiohttp.ClientSession() as session: - async with session.get(f"{_BASE_URL}/2.0/accounts", headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp: + async with session.get(self._buildUrl(config, "2.0/accounts"), headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status != 200: return [] accounts = await resp.json() @@ -139,7 +172,7 @@ class AccountingConnectorBexio(BaseAccountingConnector): } async with aiohttp.ClientSession() as session: - url = f"{_BASE_URL}/3.0/accounting/manual-entries" + url = self._buildUrl(config, "3.0/accounting/manual-entries") async with session.post(url, headers=self._buildHeaders(config), json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp: body = await resp.json() if resp.content_type == "application/json" else {"raw": await resp.text()} if resp.status in (200, 201): @@ -152,7 +185,7 @@ class AccountingConnectorBexio(BaseAccountingConnector): async def getBookingStatus(self, config: Dict[str, Any], externalId: str) -> SyncResult: try: async with aiohttp.ClientSession() as session: - url = f"{_BASE_URL}/3.0/accounting/manual-entries/{externalId}" + url = self._buildUrl(config, f"3.0/accounting/manual-entries/{externalId}") async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp: if resp.status == 200: return SyncResult(success=True, externalId=externalId) @@ -163,7 +196,7 @@ class AccountingConnectorBexio(BaseAccountingConnector): async def getCustomers(self, config: Dict[str, Any]) -> List[Dict[str, Any]]: try: async with aiohttp.ClientSession() as session: - async with session.get(f"{_BASE_URL}/2.0/contact", headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp: + async with session.get(self._buildUrl(config, "2.0/contact"), headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status != 200: return [] return await resp.json() diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py index 86dbcac9..fa93ff40 100644 --- a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py +++ b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py @@ -27,7 +27,7 @@ from ..accountingConnectorBase import ( logger = logging.getLogger(__name__) -_BASE_URL = "https://service.runmyaccounts.com/api/latest/clients" +_DEFAULT_API_BASE_URL = "https://service.runmyaccounts.com/api/latest/clients/" class AccountingConnectorRma(BaseAccountingConnector): @@ -40,6 +40,13 @@ class AccountingConnectorRma(BaseAccountingConnector): def getRequiredConfigFields(self) -> List[ConnectorConfigField]: return [ + ConnectorConfigField( + key="apiBaseUrl", + label={"en": "API Base URL", "de": "API Base URL", "fr": "URL de base API"}, + fieldType="text", + secret=False, + placeholder="https://service.runmyaccounts.com/api/latest/clients/", + ), ConnectorConfigField( key="clientName", label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"}, @@ -56,8 +63,15 @@ class AccountingConnectorRma(BaseAccountingConnector): ] def _buildUrl(self, config: Dict[str, Any], resource: str) -> str: - clientName = config.get("clientName", "") - return f"{_BASE_URL}/{clientName}/{resource}" + apiBaseUrl = str(config.get("apiBaseUrl") or "").strip() + if not apiBaseUrl: + raise ValueError("Missing required config: apiBaseUrl") + apiBaseUrl = apiBaseUrl.rstrip("/") + "/" + + clientName = str(config.get("clientName") or "").strip() + if not clientName: + raise ValueError("Missing required config: clientName") + return f"{apiBaseUrl}{clientName}/{resource}" def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]: apiKey = config.get("apiKey", "") @@ -75,8 +89,15 @@ class AccountingConnectorRma(BaseAccountingConnector): clientName = config.get("clientName", "") apiKey = config.get("apiKey", "") - if not clientName or not apiKey: - return SyncResult(success=False, errorMessage=f"Missing credentials: clientName={bool(clientName)}, apiKey={bool(apiKey)}") + apiBaseUrl = str(config.get("apiBaseUrl") or "") + if not clientName or not apiKey or not apiBaseUrl: + return SyncResult( + success=False, + errorMessage=( + f"Missing credentials: apiBaseUrl={bool(apiBaseUrl)}, " + f"clientName={bool(clientName)}, apiKey={bool(apiKey)}" + ), + ) url = self._buildUrl(config, "customers") headers = self._buildHeaders(config) diff --git a/modules/serviceCenter/services/serviceAgent/agentLoop.py b/modules/serviceCenter/services/serviceAgent/agentLoop.py index 2872c97a..eaa3bd75 100644 --- a/modules/serviceCenter/services/serviceAgent/agentLoop.py +++ b/modules/serviceCenter/services/serviceAgent/agentLoop.py @@ -312,25 +312,87 @@ async def _checkBudget(config: AgentConfig, async def _executeToolCalls(toolCalls: List[ToolCallRequest], toolRegistry: ToolRegistry, context: Dict[str, Any]) -> List[ToolResult]: - """Execute tool calls: readOnly tools in parallel, others sequentially.""" + """Execute tool calls: readOnly tools in parallel, others sequentially. + + Tool calls with _parseError (truncated JSON from LLM) are short-circuited + with an error result so the agent can retry. + """ readOnlyCalls = [tc for tc in toolCalls if toolRegistry.isReadOnly(tc.name)] writeCalls = [tc for tc in toolCalls if not toolRegistry.isReadOnly(tc.name)] results: Dict[str, ToolResult] = {} - if readOnlyCalls: + for tc in toolCalls: + if "_parseError" in tc.args: + results[tc.id] = ToolResult( + toolCallId=tc.id, + toolName=tc.name, + success=False, + data="", + error=tc.args["_parseError"], + durationMs=0, + ) + + activeCalls = [tc for tc in toolCalls if tc.id not in results] + activeReadOnly = [tc for tc in activeCalls if toolRegistry.isReadOnly(tc.name)] + activeWrite = [tc for tc in activeCalls if not toolRegistry.isReadOnly(tc.name)] + + if activeReadOnly: readResults = await asyncio.gather(*[ - toolRegistry.dispatch(tc, context) for tc in readOnlyCalls + toolRegistry.dispatch(tc, context) for tc in activeReadOnly ]) - for tc, result in zip(readOnlyCalls, readResults): + for tc, result in zip(activeReadOnly, readResults): results[tc.id] = result - for tc in writeCalls: + for tc in activeWrite: results[tc.id] = await toolRegistry.dispatch(tc, context) return [results[tc.id] for tc in toolCalls] +def _repairTruncatedJson(raw: str) -> Optional[Dict[str, Any]]: + """Try to repair truncated JSON from LLM output by closing open brackets/braces. + + Returns parsed dict on success, None if unrecoverable. + """ + if not raw or not raw.strip().startswith("{"): + return None + + openBraces = raw.count("{") - raw.count("}") + openBrackets = raw.count("[") - raw.count("]") + + inString = False + lastQuoteEscaped = False + quoteCount = 0 + for ch in raw: + if ch == '"' and not lastQuoteEscaped: + quoteCount += 1 + inString = not inString + lastQuoteEscaped = (ch == '\\') + + candidate = raw + if quoteCount % 2 != 0: + candidate += '"' + + candidate += "]" * max(0, openBrackets) + candidate += "}" * max(0, openBraces) + + try: + return json.loads(candidate) + except json.JSONDecodeError: + pass + + lastComma = candidate.rfind(",") + if lastComma > 0: + trimmed = candidate[:lastComma] + candidate[lastComma + 1:] + try: + return json.loads(trimmed) + except json.JSONDecodeError: + pass + + return None + + def _parseToolCalls(aiResponse: AiCallResponse) -> List[ToolCallRequest]: """Parse tool calls from AI response. Supports native function calling and text-based fallback.""" toolCalls = [] @@ -344,8 +406,12 @@ def _parseToolCalls(aiResponse: AiCallResponse) -> List[ToolCallRequest]: try: parsedArgs = json.loads(rawArgs) if rawArgs else {} except json.JSONDecodeError: - logger.warning(f"Failed to parse tool args for '{tc['function']['name']}': {rawArgs[:200]}") - parsedArgs = {} + parsedArgs = _repairTruncatedJson(rawArgs) + if parsedArgs is None: + logger.warning(f"Unrecoverable truncated JSON for '{tc['function']['name']}': {rawArgs[:200]}") + parsedArgs = {"_parseError": f"Truncated JSON arguments – model output was cut off. Raw start: {rawArgs[:120]}"} + else: + logger.info(f"Repaired truncated JSON for '{tc['function']['name']}'") else: parsedArgs = rawArgs if rawArgs else {} toolCalls.append(ToolCallRequest(