426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
516 lines
18 KiB
Python
516 lines
18 KiB
Python
"""
|
|
BFCL Function Call Parser
|
|
|
|
Parses function calls from various response formats including:
|
|
- JSON format
|
|
- XML format (<params>)
|
|
- Natural language with tool use markers
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import Optional
|
|
from xml.etree import ElementTree
|
|
|
|
from benchmarks.bfcl.types import ArgumentValue, FunctionCall
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FunctionCallParser:
|
|
"""
|
|
Parse function calls from agent responses.
|
|
|
|
Supports multiple formats:
|
|
- JSON: {"name": "func", "arguments": {...}}
|
|
- JSON array: [{"name": "func", "arguments": {...}}, ...]
|
|
- XML: <function_call><name>func</name><arguments>...</arguments></function_call>
|
|
- ElizaOS params: <params><FUNC><arg>value</arg></FUNC></params>
|
|
- Natural language: "I'll call func(arg=value)"
|
|
"""
|
|
|
|
# Patterns for extracting function calls
|
|
JSON_PATTERN = re.compile(
|
|
r'\{[^{}]*"name"\s*:\s*"([^"]+)"[^{}]*"arguments"\s*:\s*(\{[^{}]*\})[^{}]*\}',
|
|
re.DOTALL,
|
|
)
|
|
FUNCTION_CALL_PATTERN = re.compile(
|
|
r'(\w+)\s*\(\s*([^)]*)\s*\)',
|
|
re.DOTALL,
|
|
)
|
|
XML_FUNCTION_PATTERN = re.compile(
|
|
r'<function_call>(.*?)</function_call>',
|
|
re.DOTALL | re.IGNORECASE,
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
prefer_json: bool = True,
|
|
extract_from_text: bool = True,
|
|
):
|
|
"""
|
|
Initialize parser.
|
|
|
|
Args:
|
|
prefer_json: If True, try JSON parsing first
|
|
extract_from_text: If True, try to extract from natural language
|
|
"""
|
|
self.prefer_json = prefer_json
|
|
self.extract_from_text = extract_from_text
|
|
|
|
def parse(self, response: str) -> list[FunctionCall]:
|
|
"""
|
|
Extract function calls from a response string.
|
|
|
|
Args:
|
|
response: The raw response text
|
|
|
|
Returns:
|
|
List of parsed function calls
|
|
"""
|
|
if not response or not response.strip():
|
|
return []
|
|
|
|
calls: list[FunctionCall] = []
|
|
|
|
# Try JSON parsing first
|
|
if self.prefer_json:
|
|
json_calls = self._parse_json(response)
|
|
if json_calls:
|
|
return json_calls
|
|
|
|
# Try XML parsing
|
|
xml_calls = self._parse_xml(response)
|
|
if xml_calls:
|
|
return xml_calls
|
|
|
|
# Try ElizaOS params format
|
|
params_calls = self._parse_elizaos_params(response)
|
|
if params_calls:
|
|
return params_calls
|
|
|
|
# Try natural language extraction
|
|
if self.extract_from_text:
|
|
text_calls = self._parse_natural_language(response)
|
|
if text_calls:
|
|
return text_calls
|
|
|
|
return calls
|
|
|
|
def _parse_json(self, response: str) -> list[FunctionCall]:
|
|
"""Parse JSON formatted function calls."""
|
|
calls: list[FunctionCall] = []
|
|
|
|
# Try to parse as full JSON first
|
|
try:
|
|
# Look for JSON blocks in code fences
|
|
json_blocks = re.findall(
|
|
r'```(?:json)?\s*([\s\S]*?)```',
|
|
response,
|
|
)
|
|
for block in json_blocks:
|
|
parsed = json.loads(block)
|
|
calls.extend(self._extract_calls_from_json(parsed))
|
|
if calls:
|
|
return calls
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
# Try to find JSON objects directly
|
|
try:
|
|
# Find all potential JSON objects
|
|
start = response.find('{')
|
|
while start != -1:
|
|
# Try to find matching closing brace
|
|
depth = 0
|
|
end = start
|
|
for i, char in enumerate(response[start:], start):
|
|
if char == '{':
|
|
depth += 1
|
|
elif char == '}':
|
|
depth -= 1
|
|
if depth == 0:
|
|
end = i + 1
|
|
break
|
|
|
|
if end > start:
|
|
try:
|
|
obj = json.loads(response[start:end])
|
|
extracted = self._extract_calls_from_json(obj)
|
|
calls.extend(extracted)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
start = response.find('{', end)
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
return calls
|
|
|
|
def _extract_calls_from_json(self, data: object) -> list[FunctionCall]:
|
|
"""Extract function calls from parsed JSON data."""
|
|
calls: list[FunctionCall] = []
|
|
|
|
if isinstance(data, list):
|
|
for item in data:
|
|
calls.extend(self._extract_calls_from_json(item))
|
|
elif isinstance(data, dict):
|
|
# Direct function call format
|
|
if "name" in data and ("arguments" in data or "parameters" in data):
|
|
name = str(data["name"])
|
|
args = data.get("arguments", data.get("parameters", {}))
|
|
if isinstance(args, str):
|
|
try:
|
|
args = json.loads(args)
|
|
except json.JSONDecodeError:
|
|
args = {}
|
|
if isinstance(args, dict):
|
|
# Normalize arguments to proper types
|
|
normalized_args = self._normalize_arguments(args)
|
|
calls.append(FunctionCall(name=name, arguments=normalized_args))
|
|
|
|
# Benchmark wrapper format: {"calls": [{"name": ..., "arguments": ...}]}
|
|
elif "calls" in data:
|
|
calls_raw = data.get("calls")
|
|
if isinstance(calls_raw, list):
|
|
for item in calls_raw:
|
|
calls.extend(self._extract_calls_from_json(item))
|
|
elif isinstance(calls_raw, dict):
|
|
calls.extend(self._extract_calls_from_json(calls_raw))
|
|
|
|
# Captured action format: {"action": "BENCHMARK_ACTION", "arg_0": {"calls": [...]}}
|
|
elif data.get("action") == "BENCHMARK_ACTION":
|
|
for key in ("arg_0", "payload", "params", "arguments"):
|
|
nested = data.get(key)
|
|
if isinstance(nested, (dict, list)):
|
|
calls.extend(self._extract_calls_from_json(nested))
|
|
|
|
# Function wrapper format
|
|
elif "function" in data:
|
|
func = data["function"]
|
|
if isinstance(func, dict):
|
|
calls.extend(self._extract_calls_from_json(func))
|
|
|
|
# Tool calls format (OpenAI style)
|
|
elif "tool_calls" in data:
|
|
tool_calls = data["tool_calls"]
|
|
if isinstance(tool_calls, list):
|
|
for tc in tool_calls:
|
|
if isinstance(tc, dict):
|
|
func = tc.get("function", {})
|
|
if isinstance(func, dict):
|
|
calls.extend(self._extract_calls_from_json(func))
|
|
|
|
return calls
|
|
|
|
def _normalize_arguments(self, args: dict[str, object]) -> dict[str, ArgumentValue]:
|
|
"""Normalize argument dict to proper types."""
|
|
normalized: dict[str, ArgumentValue] = {}
|
|
for key, value in args.items():
|
|
# First normalize the JSON value
|
|
norm_val = self._normalize_json_value(value)
|
|
# Then apply type coercion and array flattening
|
|
norm_val = self._coerce_types(norm_val)
|
|
norm_val = self._flatten_single_element_arrays(norm_val)
|
|
normalized[str(key)] = norm_val
|
|
return normalized
|
|
|
|
def _coerce_types(self, value: ArgumentValue) -> ArgumentValue:
|
|
"""Coerce string values to proper types where possible."""
|
|
if isinstance(value, str):
|
|
# Try to convert string numbers to actual numbers
|
|
if value.isdigit():
|
|
return int(value)
|
|
try:
|
|
# Check for float-like strings
|
|
if '.' in value or 'e' in value.lower():
|
|
return float(value)
|
|
except ValueError:
|
|
pass
|
|
# Try negative numbers
|
|
if value.startswith('-') and value[1:].isdigit():
|
|
return int(value)
|
|
elif isinstance(value, list):
|
|
return [self._coerce_types(v) for v in value]
|
|
elif isinstance(value, dict):
|
|
return {k: self._coerce_types(v) for k, v in value.items()}
|
|
return value
|
|
|
|
def _flatten_single_element_arrays(self, value: ArgumentValue) -> ArgumentValue:
|
|
"""
|
|
Flatten unnecessarily nested single-element arrays.
|
|
|
|
E.g., [["value"]] -> ["value"]
|
|
"""
|
|
if isinstance(value, list):
|
|
# Recursively process list elements
|
|
processed = [self._flatten_single_element_arrays(v) for v in value]
|
|
# Check if this is a single-element list containing another list
|
|
if len(processed) == 1 and isinstance(processed[0], list):
|
|
# Flatten: [[a, b]] -> [a, b]
|
|
return processed[0]
|
|
return processed
|
|
elif isinstance(value, dict):
|
|
return {k: self._flatten_single_element_arrays(v) for k, v in value.items()}
|
|
return value
|
|
|
|
def _parse_xml(self, response: str) -> list[FunctionCall]:
|
|
"""Parse XML formatted function calls."""
|
|
calls: list[FunctionCall] = []
|
|
|
|
# Find function_call XML blocks
|
|
matches = self.XML_FUNCTION_PATTERN.findall(response)
|
|
for match in matches:
|
|
try:
|
|
# Wrap in root element if needed
|
|
xml_str = f"<root>{match}</root>"
|
|
root = ElementTree.fromstring(xml_str)
|
|
|
|
name_elem = root.find(".//name")
|
|
args_elem = root.find(".//arguments")
|
|
|
|
if name_elem is not None and name_elem.text:
|
|
name = name_elem.text.strip()
|
|
arguments: dict[str, ArgumentValue] = {}
|
|
|
|
if args_elem is not None:
|
|
# Try parsing arguments as JSON
|
|
if args_elem.text:
|
|
try:
|
|
arguments = json.loads(args_elem.text)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
# Or extract from child elements
|
|
else:
|
|
for child in args_elem:
|
|
if child.text:
|
|
arguments[child.tag] = self._parse_value(child.text)
|
|
|
|
calls.append(FunctionCall(name=name, arguments=arguments))
|
|
|
|
except (ElementTree.ParseError, ImportError):
|
|
fallback = self._parse_function_call_xml_fallback(match)
|
|
if fallback is not None:
|
|
calls.append(fallback)
|
|
|
|
return calls
|
|
|
|
def _parse_elizaos_params(self, response: str) -> list[FunctionCall]:
|
|
"""Parse ElizaOS <params> format."""
|
|
calls: list[FunctionCall] = []
|
|
|
|
# Find params blocks
|
|
params_pattern = re.compile(
|
|
r'<params>(.*?)</params>',
|
|
re.DOTALL | re.IGNORECASE,
|
|
)
|
|
matches = params_pattern.findall(response)
|
|
|
|
for match in matches:
|
|
try:
|
|
# Parse as XML
|
|
xml_str = f"<root>{match}</root>"
|
|
root = ElementTree.fromstring(xml_str)
|
|
|
|
# Each child of root is an action/function
|
|
for action_elem in root:
|
|
name = action_elem.tag
|
|
arguments: dict[str, ArgumentValue] = {}
|
|
|
|
# Each child of action is a parameter
|
|
for param_elem in action_elem:
|
|
if param_elem.text:
|
|
arguments[param_elem.tag] = self._parse_value(param_elem.text)
|
|
|
|
if name.lower() != "root":
|
|
calls.append(FunctionCall(name=name, arguments=arguments))
|
|
|
|
except (ElementTree.ParseError, ImportError):
|
|
calls.extend(self._parse_params_xml_fallback(match))
|
|
|
|
return calls
|
|
|
|
def _parse_function_call_xml_fallback(self, xml_body: str) -> Optional[FunctionCall]:
|
|
"""Parse the simple BFCL XML shape without ElementTree/pyexpat.
|
|
|
|
Some CI and local Python builds have a broken pyexpat extension. BFCL
|
|
XML responses use a small fixed wrapper, so a conservative fallback
|
|
avoids turning parser availability into benchmark false negatives.
|
|
"""
|
|
name_match = re.search(
|
|
r"<name>\s*(.*?)\s*</name>",
|
|
xml_body,
|
|
re.DOTALL | re.IGNORECASE,
|
|
)
|
|
if not name_match:
|
|
return None
|
|
|
|
name = name_match.group(1).strip()
|
|
arguments: dict[str, ArgumentValue] = {}
|
|
args_match = re.search(
|
|
r"<arguments>\s*(.*?)\s*</arguments>",
|
|
xml_body,
|
|
re.DOTALL | re.IGNORECASE,
|
|
)
|
|
if args_match:
|
|
args_text = args_match.group(1).strip()
|
|
if args_text:
|
|
try:
|
|
parsed = json.loads(args_text)
|
|
if isinstance(parsed, dict):
|
|
arguments = self._normalize_arguments(parsed)
|
|
except json.JSONDecodeError:
|
|
arguments = self._parse_xml_scalar_children(args_text)
|
|
|
|
return FunctionCall(name=name, arguments=arguments)
|
|
|
|
def _parse_params_xml_fallback(self, xml_body: str) -> list[FunctionCall]:
|
|
"""Parse ElizaOS params XML without ElementTree/pyexpat."""
|
|
calls: list[FunctionCall] = []
|
|
for action_match in re.finditer(
|
|
r"<([A-Za-z_][\w.-]*)\b[^>]*>(.*?)</\1>",
|
|
xml_body,
|
|
re.DOTALL,
|
|
):
|
|
name = action_match.group(1)
|
|
body = action_match.group(2)
|
|
if name.lower() in {"params", "root"}:
|
|
continue
|
|
calls.append(
|
|
FunctionCall(
|
|
name=name,
|
|
arguments=self._parse_xml_scalar_children(body),
|
|
)
|
|
)
|
|
return calls
|
|
|
|
def _parse_xml_scalar_children(self, xml_body: str) -> dict[str, ArgumentValue]:
|
|
"""Extract direct scalar child tags from a simple XML fragment."""
|
|
arguments: dict[str, ArgumentValue] = {}
|
|
for param_match in re.finditer(
|
|
r"<([A-Za-z_][\w.-]*)\b[^>]*>(.*?)</\1>",
|
|
xml_body,
|
|
re.DOTALL,
|
|
):
|
|
key = param_match.group(1)
|
|
value = param_match.group(2).strip()
|
|
if value:
|
|
arguments[key] = self._parse_value(value)
|
|
return arguments
|
|
|
|
def _parse_natural_language(self, response: str) -> list[FunctionCall]:
|
|
"""Extract function calls from natural language text."""
|
|
calls: list[FunctionCall] = []
|
|
|
|
# Look for function call patterns like "func(arg=value)"
|
|
matches = self.FUNCTION_CALL_PATTERN.findall(response)
|
|
|
|
for name, args_str in matches:
|
|
# Skip common non-function words
|
|
if name.lower() in (
|
|
"if", "for", "while", "with", "def", "class",
|
|
"return", "print", "input", "len", "str", "int",
|
|
):
|
|
continue
|
|
|
|
arguments = self._parse_arguments_string(args_str)
|
|
if arguments is not None:
|
|
calls.append(FunctionCall(name=name, arguments=arguments))
|
|
|
|
return calls
|
|
|
|
def _parse_arguments_string(self, args_str: str) -> Optional[dict[str, ArgumentValue]]:
|
|
"""Parse a comma-separated arguments string."""
|
|
if not args_str.strip():
|
|
return {}
|
|
|
|
arguments: dict[str, ArgumentValue] = {}
|
|
|
|
# Split by comma, but respect nested structures
|
|
parts = self._split_arguments(args_str)
|
|
|
|
for part in parts:
|
|
part = part.strip()
|
|
if "=" in part:
|
|
key, value = part.split("=", 1)
|
|
arguments[key.strip()] = self._parse_value(value.strip())
|
|
elif part:
|
|
# Positional argument - use index as key
|
|
arguments[f"arg_{len(arguments)}"] = self._parse_value(part)
|
|
|
|
return arguments
|
|
|
|
def _split_arguments(self, args_str: str) -> list[str]:
|
|
"""Split arguments string respecting nested structures."""
|
|
parts: list[str] = []
|
|
current = ""
|
|
depth = 0
|
|
|
|
for char in args_str:
|
|
if char in "([{":
|
|
depth += 1
|
|
current += char
|
|
elif char in ")]}":
|
|
depth -= 1
|
|
current += char
|
|
elif char == "," and depth == 0:
|
|
parts.append(current)
|
|
current = ""
|
|
else:
|
|
current += char
|
|
|
|
if current:
|
|
parts.append(current)
|
|
|
|
return parts
|
|
|
|
def _parse_value(self, value: str) -> ArgumentValue:
|
|
"""Parse a string value into appropriate Python type."""
|
|
value = value.strip()
|
|
|
|
# Remove quotes
|
|
if (value.startswith('"') and value.endswith('"')) or \
|
|
(value.startswith("'") and value.endswith("'")):
|
|
return value[1:-1]
|
|
|
|
# Try boolean
|
|
if value.lower() == "true":
|
|
return True
|
|
if value.lower() == "false":
|
|
return False
|
|
if value.lower() in ("null", "none"):
|
|
return None
|
|
|
|
# Try numeric
|
|
try:
|
|
if "." in value:
|
|
return float(value)
|
|
return int(value)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Try JSON
|
|
try:
|
|
parsed = json.loads(value)
|
|
return self._normalize_json_value(parsed)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
return value
|
|
|
|
def _normalize_json_value(self, value: object) -> ArgumentValue:
|
|
"""Normalize a JSON-parsed value to ArgumentValue type."""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, (str, int, float, bool)):
|
|
return value
|
|
if isinstance(value, list):
|
|
return [self._normalize_json_value(v) for v in value]
|
|
if isinstance(value, dict):
|
|
return {str(k): self._normalize_json_value(v) for k, v in value.items()}
|
|
return str(value)
|