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(