fixed trustee rma connector
This commit is contained in:
parent
4e843761a9
commit
587dd7489e
4 changed files with 181 additions and 31 deletions
|
|
@ -40,15 +40,15 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
|
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
|
||||||
return [
|
return [
|
||||||
ConnectorConfigField(
|
ConnectorConfigField(
|
||||||
key="abacusHost",
|
key="apiBaseUrl",
|
||||||
label={"en": "Abacus Host URL", "de": "Abacus Host-URL", "fr": "URL Hôte Abacus"},
|
label={"en": "API Base URL", "de": "API Base URL", "fr": "URL de base API"},
|
||||||
fieldType="text",
|
fieldType="text",
|
||||||
secret=False,
|
secret=False,
|
||||||
placeholder="e.g. abacus.meinefirma.ch",
|
placeholder="e.g. https://abacus.meinefirma.ch/api/entity/v1/",
|
||||||
),
|
),
|
||||||
ConnectorConfigField(
|
ConnectorConfigField(
|
||||||
key="mandant",
|
key="clientName",
|
||||||
label={"en": "Mandant Number", "de": "Mandantennummer", "fr": "Numéro de mandant"},
|
label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"},
|
||||||
fieldType="text",
|
fieldType="text",
|
||||||
secret=False,
|
secret=False,
|
||||||
placeholder="e.g. 7777",
|
placeholder="e.g. 7777",
|
||||||
|
|
@ -68,22 +68,37 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
]
|
]
|
||||||
|
|
||||||
def _buildBaseUrl(self, config: Dict[str, Any]) -> str:
|
def _buildBaseUrl(self, config: Dict[str, Any]) -> str:
|
||||||
host = config["abacusHost"].rstrip("/")
|
apiBaseUrl = str(config.get("apiBaseUrl") or "").strip()
|
||||||
if not host.startswith("http"):
|
if not apiBaseUrl:
|
||||||
host = f"https://{host}"
|
raise ValueError("Missing required config: apiBaseUrl")
|
||||||
return host
|
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]:
|
async def _getAccessToken(self, config: Dict[str, Any]) -> Optional[str]:
|
||||||
"""Obtain an OAuth access token using client_credentials grant.
|
"""Obtain an OAuth access token using client_credentials grant.
|
||||||
|
|
||||||
Tokens are cached and refreshed when expired (default 600s).
|
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)
|
cached = self._tokenCache.get(cacheKey)
|
||||||
if cached and cached.get("expiresAt", 0) > time.time() + 30:
|
if cached and cached.get("expiresAt", 0) > time.time() + 30:
|
||||||
return cached["accessToken"]
|
return cached["accessToken"]
|
||||||
|
|
||||||
baseUrl = self._buildBaseUrl(config)
|
baseUrl = self._buildAuthBaseUrl(config)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
|
|
@ -120,8 +135,10 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
|
|
||||||
def _buildEntityUrl(self, config: Dict[str, Any], entity: str) -> str:
|
def _buildEntityUrl(self, config: Dict[str, Any], entity: str) -> str:
|
||||||
baseUrl = self._buildBaseUrl(config)
|
baseUrl = self._buildBaseUrl(config)
|
||||||
mandant = config["mandant"]
|
clientName = config.get("clientName")
|
||||||
return f"{baseUrl}/api/entity/v1/{mandant}/{entity}"
|
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]]:
|
async def _buildAuthHeaders(self, config: Dict[str, Any]) -> Optional[Dict[str, str]]:
|
||||||
token = await self._getAccessToken(config)
|
token = await self._getAccessToken(config)
|
||||||
|
|
@ -130,6 +147,19 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
|
||||||
return {"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"}
|
return {"Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json"}
|
||||||
|
|
||||||
async def testConnection(self, config: Dict[str, Any]) -> SyncResult:
|
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)
|
headers = await self._buildAuthHeaders(config)
|
||||||
if not headers:
|
if not headers:
|
||||||
return SyncResult(success=False, errorMessage="Failed to obtain access token")
|
return SyncResult(success=False, errorMessage="Failed to obtain access token")
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ from ..accountingConnectorBase import (
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_BASE_URL = "https://api.bexio.com"
|
_DEFAULT_API_BASE_URL = "https://api.bexio.com/"
|
||||||
|
|
||||||
|
|
||||||
class AccountingConnectorBexio(BaseAccountingConnector):
|
class AccountingConnectorBexio(BaseAccountingConnector):
|
||||||
|
|
@ -40,6 +40,20 @@ class AccountingConnectorBexio(BaseAccountingConnector):
|
||||||
|
|
||||||
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
|
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
|
||||||
return [
|
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(
|
ConnectorConfigField(
|
||||||
key="accessToken",
|
key="accessToken",
|
||||||
label={"en": "Personal Access Token", "de": "Persönlicher Zugriffstoken", "fr": "Jeton d'accès personnel"},
|
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]:
|
def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"Authorization": f"Bearer {config['accessToken']}",
|
"Authorization": f"Bearer {config['accessToken']}",
|
||||||
|
|
@ -57,9 +79,20 @@ class AccountingConnectorBexio(BaseAccountingConnector):
|
||||||
}
|
}
|
||||||
|
|
||||||
async def testConnection(self, config: Dict[str, Any]) -> SyncResult:
|
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:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
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:
|
if resp.status == 200:
|
||||||
return SyncResult(success=True)
|
return SyncResult(success=True)
|
||||||
body = await resp.text()
|
body = await resp.text()
|
||||||
|
|
@ -75,7 +108,7 @@ class AccountingConnectorBexio(BaseAccountingConnector):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
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:
|
if resp.status != 200:
|
||||||
return []
|
return []
|
||||||
accounts = await resp.json()
|
accounts = await resp.json()
|
||||||
|
|
@ -139,7 +172,7 @@ class AccountingConnectorBexio(BaseAccountingConnector):
|
||||||
}
|
}
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
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:
|
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()}
|
body = await resp.json() if resp.content_type == "application/json" else {"raw": await resp.text()}
|
||||||
if resp.status in (200, 201):
|
if resp.status in (200, 201):
|
||||||
|
|
@ -152,7 +185,7 @@ class AccountingConnectorBexio(BaseAccountingConnector):
|
||||||
async def getBookingStatus(self, config: Dict[str, Any], externalId: str) -> SyncResult:
|
async def getBookingStatus(self, config: Dict[str, Any], externalId: str) -> SyncResult:
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
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:
|
async with session.get(url, headers=self._buildHeaders(config), timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||||
if resp.status == 200:
|
if resp.status == 200:
|
||||||
return SyncResult(success=True, externalId=externalId)
|
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]]:
|
async def getCustomers(self, config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
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:
|
if resp.status != 200:
|
||||||
return []
|
return []
|
||||||
return await resp.json()
|
return await resp.json()
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ from ..accountingConnectorBase import (
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class AccountingConnectorRma(BaseAccountingConnector):
|
||||||
|
|
@ -40,6 +40,13 @@ class AccountingConnectorRma(BaseAccountingConnector):
|
||||||
|
|
||||||
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
|
def getRequiredConfigFields(self) -> List[ConnectorConfigField]:
|
||||||
return [
|
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(
|
ConnectorConfigField(
|
||||||
key="clientName",
|
key="clientName",
|
||||||
label={"en": "Client Name", "de": "Mandantenname", "fr": "Nom du client"},
|
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:
|
def _buildUrl(self, config: Dict[str, Any], resource: str) -> str:
|
||||||
clientName = config.get("clientName", "")
|
apiBaseUrl = str(config.get("apiBaseUrl") or "").strip()
|
||||||
return f"{_BASE_URL}/{clientName}/{resource}"
|
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]:
|
def _buildHeaders(self, config: Dict[str, Any]) -> Dict[str, str]:
|
||||||
apiKey = config.get("apiKey", "")
|
apiKey = config.get("apiKey", "")
|
||||||
|
|
@ -75,8 +89,15 @@ class AccountingConnectorRma(BaseAccountingConnector):
|
||||||
clientName = config.get("clientName", "")
|
clientName = config.get("clientName", "")
|
||||||
apiKey = config.get("apiKey", "")
|
apiKey = config.get("apiKey", "")
|
||||||
|
|
||||||
if not clientName or not apiKey:
|
apiBaseUrl = str(config.get("apiBaseUrl") or "")
|
||||||
return SyncResult(success=False, errorMessage=f"Missing credentials: clientName={bool(clientName)}, apiKey={bool(apiKey)}")
|
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")
|
url = self._buildUrl(config, "customers")
|
||||||
headers = self._buildHeaders(config)
|
headers = self._buildHeaders(config)
|
||||||
|
|
|
||||||
|
|
@ -312,25 +312,87 @@ async def _checkBudget(config: AgentConfig,
|
||||||
async def _executeToolCalls(toolCalls: List[ToolCallRequest],
|
async def _executeToolCalls(toolCalls: List[ToolCallRequest],
|
||||||
toolRegistry: ToolRegistry,
|
toolRegistry: ToolRegistry,
|
||||||
context: Dict[str, Any]) -> List[ToolResult]:
|
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)]
|
readOnlyCalls = [tc for tc in toolCalls if toolRegistry.isReadOnly(tc.name)]
|
||||||
writeCalls = [tc for tc in toolCalls if not toolRegistry.isReadOnly(tc.name)]
|
writeCalls = [tc for tc in toolCalls if not toolRegistry.isReadOnly(tc.name)]
|
||||||
|
|
||||||
results: Dict[str, ToolResult] = {}
|
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(*[
|
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
|
results[tc.id] = result
|
||||||
|
|
||||||
for tc in writeCalls:
|
for tc in activeWrite:
|
||||||
results[tc.id] = await toolRegistry.dispatch(tc, context)
|
results[tc.id] = await toolRegistry.dispatch(tc, context)
|
||||||
|
|
||||||
return [results[tc.id] for tc in toolCalls]
|
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]:
|
def _parseToolCalls(aiResponse: AiCallResponse) -> List[ToolCallRequest]:
|
||||||
"""Parse tool calls from AI response. Supports native function calling and text-based fallback."""
|
"""Parse tool calls from AI response. Supports native function calling and text-based fallback."""
|
||||||
toolCalls = []
|
toolCalls = []
|
||||||
|
|
@ -344,8 +406,12 @@ def _parseToolCalls(aiResponse: AiCallResponse) -> List[ToolCallRequest]:
|
||||||
try:
|
try:
|
||||||
parsedArgs = json.loads(rawArgs) if rawArgs else {}
|
parsedArgs = json.loads(rawArgs) if rawArgs else {}
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.warning(f"Failed to parse tool args for '{tc['function']['name']}': {rawArgs[:200]}")
|
parsedArgs = _repairTruncatedJson(rawArgs)
|
||||||
parsedArgs = {}
|
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:
|
else:
|
||||||
parsedArgs = rawArgs if rawArgs else {}
|
parsedArgs = rawArgs if rawArgs else {}
|
||||||
toolCalls.append(ToolCallRequest(
|
toolCalls.append(ToolCallRequest(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue