first
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Function parser for extracting function names, parameter names, and values from string function calls.
|
||||
|
||||
Uses Python's ``tokenize`` and ``ast`` modules so that nested parentheses,
|
||||
strings containing ')' characters, tuples, dicts, etc. are handled correctly.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import tokenize
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
from pydantic import BaseModel
|
||||
|
||||
from speech_to_speech.LLM.tool_call.function_tool import FunctionTool
|
||||
from speech_to_speech.utils.utils import _generate_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_POSITIONAL_RE = re.compile(r"^__arg_\d+__$")
|
||||
_LENIENT_CALL_RE = re.compile(
|
||||
r"\b[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*"
|
||||
r"\((?:[^()\"']+|\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*')*\)"
|
||||
)
|
||||
|
||||
|
||||
# ── AST / tokenize helpers ───────────────────────────────────────────
|
||||
|
||||
|
||||
def _split_top_level_calls(source: str) -> List[str]:
|
||||
"""Split *source* into individual ``name(...)`` expression strings.
|
||||
|
||||
Uses the tokenizer to walk tokens and track parenthesis depth so that
|
||||
nested parens, strings with ')' chars, etc. are handled correctly.
|
||||
"""
|
||||
tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
|
||||
calls: List[str] = []
|
||||
i = 0
|
||||
|
||||
while i < len(tokens):
|
||||
tok = tokens[i]
|
||||
if tok.type != tokenize.NAME:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
start = i
|
||||
j = i + 1
|
||||
|
||||
# Walk past dotted attribute access (e.g. ``mobile.click``)
|
||||
while j + 1 < len(tokens) and tokens[j].string == "." and tokens[j + 1].type == tokenize.NAME:
|
||||
j += 2
|
||||
|
||||
if j >= len(tokens) or tokens[j].string != "(":
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Track balanced parens
|
||||
depth = 0
|
||||
end = None
|
||||
k = j
|
||||
while k < len(tokens):
|
||||
t = tokens[k]
|
||||
if t.type == tokenize.OP and t.string == "(":
|
||||
depth += 1
|
||||
elif t.type == tokenize.OP and t.string == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = k
|
||||
break
|
||||
k += 1
|
||||
|
||||
if end is None:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
calls.append(tokenize.untokenize(tokens[start : end + 1]).strip())
|
||||
i = end + 1
|
||||
|
||||
return calls
|
||||
|
||||
|
||||
def _split_simple_calls_with_regex(source: str) -> List[str]:
|
||||
"""Extract complete simple ``name(args)`` spans from malformed model output.
|
||||
|
||||
This fallback can recover well-formed siblings before a tokenizer error,
|
||||
but not the incomplete call that caused the tokenizer error.
|
||||
"""
|
||||
return [match.group(0).strip() for match in _LENIENT_CALL_RE.finditer(source)]
|
||||
|
||||
|
||||
def _parse_function_exprs(
|
||||
expressions: List[str],
|
||||
pattern_to_match: list[str],
|
||||
*,
|
||||
skip_invalid: bool = False,
|
||||
) -> List["FunctionToolCall"]:
|
||||
results: List[FunctionToolCall] = []
|
||||
for expr in expressions:
|
||||
try:
|
||||
call = _parse_call_expr(expr)
|
||||
except Exception:
|
||||
if skip_invalid:
|
||||
continue
|
||||
raise
|
||||
if pattern_to_match and all(pattern not in call.function_name for pattern in pattern_to_match):
|
||||
continue
|
||||
results.append(call)
|
||||
return results
|
||||
|
||||
|
||||
def _extract_function_name(node: ast.expr) -> str:
|
||||
"""Return the dotted function name from a Call node's ``func`` attribute."""
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
if isinstance(node, ast.Attribute):
|
||||
base = _extract_function_name(node.value)
|
||||
return f"{base}.{node.attr}" if base else node.attr
|
||||
raise ValueError(f"Unsupported function target: {ast.dump(node)}")
|
||||
|
||||
|
||||
def _literal_from_ast(node: ast.AST) -> Any:
|
||||
"""Convert an AST node to a Python literal value."""
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
|
||||
if isinstance(node, ast.List):
|
||||
return [_literal_from_ast(elt) for elt in node.elts]
|
||||
|
||||
if isinstance(node, ast.Tuple):
|
||||
return [_literal_from_ast(elt) for elt in node.elts]
|
||||
|
||||
if isinstance(node, ast.Dict):
|
||||
return {
|
||||
_literal_from_ast(key): _literal_from_ast(value)
|
||||
for key, value in zip(node.keys, node.values)
|
||||
if key is not None
|
||||
}
|
||||
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
|
||||
value = _literal_from_ast(node.operand)
|
||||
if not isinstance(value, (int, float)):
|
||||
raise ValueError(f"Unsupported unary literal: {ast.dump(node)}")
|
||||
return -value if isinstance(node.op, ast.USub) else value
|
||||
|
||||
raise ValueError(f"Unsupported literal: {ast.dump(node)}")
|
||||
|
||||
|
||||
def _parse_call_expr(expr: str) -> "FunctionToolCall":
|
||||
"""Parse a single ``name(args...)`` expression string into a FunctionToolCall."""
|
||||
parsed = ast.parse(expr, mode="eval").body
|
||||
if not isinstance(parsed, ast.Call):
|
||||
raise ValueError(f"Expression is not a function call: {expr!r}")
|
||||
|
||||
parameters: "OrderedDict[str, Any]" = OrderedDict()
|
||||
|
||||
for idx, arg in enumerate(parsed.args):
|
||||
parameters[f"__arg_{idx}__"] = _literal_from_ast(arg)
|
||||
|
||||
for kw in parsed.keywords:
|
||||
if kw.arg is None:
|
||||
raise ValueError("**kwargs are not supported")
|
||||
parameters[kw.arg] = _literal_from_ast(kw.value)
|
||||
|
||||
return FunctionToolCall(
|
||||
function_name=_extract_function_name(parsed.func),
|
||||
parameters=parameters,
|
||||
original_string=expr,
|
||||
)
|
||||
|
||||
|
||||
# ── Data model ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FunctionToolCall(BaseModel):
|
||||
"""Represents a parsed function call with its parameters."""
|
||||
|
||||
function_name: str
|
||||
parameters: Dict[str, Any]
|
||||
original_string: str
|
||||
description: str = ""
|
||||
|
||||
def to_realtime_function_tool_call(
|
||||
self,
|
||||
function_tools: list[FunctionTool] | None = None,
|
||||
) -> ResponseFunctionToolCall:
|
||||
positional = {k for k in self.parameters if _POSITIONAL_RE.match(k)}
|
||||
if positional:
|
||||
logger.warning(
|
||||
"Dropping positional arguments for '%s': %s",
|
||||
self.function_name,
|
||||
positional,
|
||||
)
|
||||
arguments = {k: v for k, v in self.parameters.items() if not _POSITIONAL_RE.match(k)}
|
||||
|
||||
if function_tools is not None:
|
||||
tool = next(
|
||||
(t for t in function_tools if t.name == self.function_name),
|
||||
None,
|
||||
)
|
||||
if tool is None:
|
||||
available = [t.name for t in function_tools]
|
||||
raise ValueError(f"Function '{self.function_name}' not found in available tools: {available}")
|
||||
|
||||
schema = tool.parameters if isinstance(tool.parameters, dict) else {}
|
||||
properties = schema.get("properties", {})
|
||||
required = set(schema.get("required", []))
|
||||
|
||||
undeclared = {k for k in arguments if k not in properties}
|
||||
if undeclared:
|
||||
logger.warning(
|
||||
"Dropping undeclared parameters for '%s': %s",
|
||||
self.function_name,
|
||||
undeclared,
|
||||
)
|
||||
arguments = {k: v for k, v in arguments.items() if k in properties}
|
||||
|
||||
missing = required - set(arguments.keys())
|
||||
if missing:
|
||||
raise ValueError(f"Missing required parameters for '{self.function_name}': {missing}")
|
||||
|
||||
return ResponseFunctionToolCall(
|
||||
name=self.function_name,
|
||||
arguments=json.dumps(arguments),
|
||||
call_id=_generate_id("call"),
|
||||
type="function_call",
|
||||
id=_generate_id("fc"),
|
||||
status="in_progress",
|
||||
)
|
||||
|
||||
|
||||
# ── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_function_call(function_string: str, pattern_to_match: list[str] = []) -> List[FunctionToolCall]:
|
||||
"""Parse a function call string and extract all function calls found.
|
||||
|
||||
Args:
|
||||
function_string: String representation of function calls.
|
||||
pattern_to_match: If non-empty, only calls whose function name
|
||||
contains at least one of these substrings are returned.
|
||||
|
||||
Returns:
|
||||
List of FunctionToolCall objects with parsed information.
|
||||
"""
|
||||
function_string = function_string.strip()
|
||||
if not function_string:
|
||||
return []
|
||||
|
||||
try:
|
||||
expressions = _split_top_level_calls(function_string)
|
||||
except tokenize.TokenError:
|
||||
return _parse_function_exprs(
|
||||
_split_simple_calls_with_regex(function_string),
|
||||
pattern_to_match,
|
||||
skip_invalid=True,
|
||||
)
|
||||
|
||||
return _parse_function_exprs(expressions, pattern_to_match)
|
||||
|
||||
|
||||
def parse_multiple_functions(function_strings: List[str]) -> List[FunctionToolCall]:
|
||||
"""Parse multiple function call strings.
|
||||
|
||||
Args:
|
||||
function_strings: List of function call strings.
|
||||
|
||||
Returns:
|
||||
List of FunctionToolCall objects.
|
||||
"""
|
||||
results: List[FunctionToolCall] = []
|
||||
for func_str in function_strings:
|
||||
try:
|
||||
results.extend(parse_function_call(func_str))
|
||||
except Exception:
|
||||
continue
|
||||
return results
|
||||
|
||||
|
||||
def extract_function_calls_from_text(text: str, block_regex: str = ".*") -> Tuple[str, List[FunctionToolCall]]:
|
||||
"""Extract function calls from delimited code blocks inside *text*.
|
||||
|
||||
The LLM is prompted to wrap tool calls inside code-block delimiters
|
||||
(e.g. ``<code>func(x=1)</code>``). This function finds those blocks,
|
||||
parses the function calls within them, and returns the remaining text
|
||||
(with blocks stripped) alongside the parsed calls.
|
||||
|
||||
Args:
|
||||
text: Full model output potentially containing code blocks.
|
||||
block_regex: Regex matching the code-block delimiters **and** their
|
||||
content (e.g. ``r"<code>.*?</code>"``). Only text **inside**
|
||||
matched blocks is scanned for function calls.
|
||||
|
||||
Returns:
|
||||
``(outside_text, function_calls)`` -- the text with blocks stripped
|
||||
and the parsed function calls found inside the blocks.
|
||||
"""
|
||||
if not block_regex:
|
||||
return text, []
|
||||
|
||||
matches = list(re.finditer(block_regex, text, flags=re.DOTALL))
|
||||
if not matches:
|
||||
return text, []
|
||||
|
||||
outside = re.sub(block_regex, "", text, flags=re.DOTALL)
|
||||
inside = " ".join(match.group(0) for match in matches).strip()
|
||||
if not inside:
|
||||
return outside, []
|
||||
|
||||
try:
|
||||
return outside, parse_function_call(inside)
|
||||
except Exception:
|
||||
return outside, []
|
||||
@@ -0,0 +1,34 @@
|
||||
import textwrap
|
||||
|
||||
from openai.types.realtime import RealtimeFunctionTool
|
||||
|
||||
from speech_to_speech.LLM.tool_call.signature_from_schema import signature_from_schema
|
||||
|
||||
|
||||
class FunctionTool(RealtimeFunctionTool):
|
||||
def to_code_prompt(self, include_args_doc: bool = True) -> str:
|
||||
"""Generate a code-style prompt string for this function tool.
|
||||
|
||||
Args:
|
||||
include_args_doc: If True, include argument descriptions in the docstring.
|
||||
⚠️ This lets the model see each argument's purpose but significantly increases
|
||||
token usage (e.g. 906 tokens without vs 3434 with for the default Reachy Mini
|
||||
tool profile). Enable depending on the model's capabilities and context limit.
|
||||
"""
|
||||
signature = signature_from_schema(self.parameters)
|
||||
|
||||
tool_doc = self.description or ""
|
||||
|
||||
if isinstance(self.parameters, dict) and include_args_doc:
|
||||
props = self.parameters.get("properties", {})
|
||||
if props:
|
||||
arg_lines = []
|
||||
for arg_name, arg_schema in props.items():
|
||||
desc = arg_schema.get("description", "") if isinstance(arg_schema, dict) else ""
|
||||
arg_lines.append(f"{arg_name}: {desc}")
|
||||
args_doc = f"Args:\n{textwrap.indent(chr(10).join(arg_lines), ' ')}"
|
||||
tool_doc += f"\n\n{args_doc}"
|
||||
|
||||
tool_doc = f'"""{tool_doc}\n"""'
|
||||
|
||||
return f"def {self.name}{signature}:\n{textwrap.indent(tool_doc, ' ')}"
|
||||
@@ -0,0 +1,108 @@
|
||||
import inspect
|
||||
from typing import Any, Literal, Union
|
||||
|
||||
JSON_TYPE_TO_PYTHON_TYPE = {
|
||||
"string": str,
|
||||
"number": float,
|
||||
"boolean": bool,
|
||||
"integer": int,
|
||||
"object": dict,
|
||||
"array": list,
|
||||
"null": type(None),
|
||||
}
|
||||
|
||||
|
||||
def _dedupe_types(types: list[Any]) -> list[Any]:
|
||||
seen = []
|
||||
for t in types:
|
||||
if t not in seen:
|
||||
seen.append(t)
|
||||
return seen
|
||||
|
||||
|
||||
def _annotation_from_spec(spec: dict[str, Any]) -> Any:
|
||||
if not spec or not isinstance(spec, dict):
|
||||
return Any
|
||||
|
||||
# const → Literal[value]
|
||||
if "const" in spec:
|
||||
return Literal[spec["const"]]
|
||||
|
||||
# enum → Literal[val1, val2, ...]
|
||||
if "enum" in spec:
|
||||
values = spec["enum"]
|
||||
if not values:
|
||||
return Any
|
||||
return Literal[tuple(values)]
|
||||
|
||||
# anyOf / oneOf → Union[Type1, Type2, ...]
|
||||
for key in ("anyOf", "oneOf"):
|
||||
if key in spec:
|
||||
variants = [_annotation_from_spec(s) for s in spec[key]]
|
||||
unique = _dedupe_types(variants)
|
||||
if len(unique) == 0:
|
||||
return Any
|
||||
if len(unique) == 1:
|
||||
return unique[0]
|
||||
return Union[tuple(unique)]
|
||||
|
||||
# allOf → merge sub-schemas then resolve
|
||||
if "allOf" in spec:
|
||||
merged = {}
|
||||
for sub in spec["allOf"]:
|
||||
merged.update(sub)
|
||||
return _annotation_from_spec(merged)
|
||||
|
||||
json_type = spec.get("type")
|
||||
|
||||
if json_type is None:
|
||||
return Any
|
||||
|
||||
# type as list, e.g. ["string", "null"] → Union[str, None] (Optional)
|
||||
if isinstance(json_type, list):
|
||||
types = [JSON_TYPE_TO_PYTHON_TYPE.get(t, Any) for t in json_type]
|
||||
unique = _dedupe_types(types)
|
||||
if len(unique) == 0:
|
||||
return Any
|
||||
if len(unique) == 1:
|
||||
return unique[0]
|
||||
return Union[tuple(unique)]
|
||||
|
||||
# array with items → list[ItemType]
|
||||
if json_type == "array" and "items" in spec:
|
||||
item_type = _annotation_from_spec(spec["items"])
|
||||
return list[item_type] # type: ignore[valid-type]
|
||||
|
||||
return JSON_TYPE_TO_PYTHON_TYPE.get(json_type, Any)
|
||||
|
||||
|
||||
def signature_from_schema(schema: object | None) -> inspect.Signature:
|
||||
if not schema or not isinstance(schema, dict):
|
||||
return inspect.Signature()
|
||||
|
||||
props = schema.get("properties", {})
|
||||
required = set(schema.get("required", []))
|
||||
params = []
|
||||
|
||||
for name, spec in props.items():
|
||||
annotation = _annotation_from_spec(spec)
|
||||
|
||||
has_schema_default = "default" in spec if isinstance(spec, dict) else False
|
||||
|
||||
if name in required and not has_schema_default:
|
||||
default = inspect.Parameter.empty
|
||||
elif has_schema_default:
|
||||
default = spec["default"]
|
||||
else:
|
||||
default = None
|
||||
|
||||
params.append(
|
||||
inspect.Parameter(
|
||||
name,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
default=default,
|
||||
annotation=annotation,
|
||||
)
|
||||
)
|
||||
|
||||
return inspect.Signature(params)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Optional system-prompt builder that instructs a local LLM to output tool calls
|
||||
inside delimited code blocks (e.g. ``<code>func(arg='val')</code>``).
|
||||
|
||||
The prompt is rendered from a Jinja2 template and relies on
|
||||
``FunctionTool.to_code_prompt()`` to expose each tool's Python-style signature.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from speech_to_speech.LLM.tool_call.function_tool import FunctionTool
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default delimiters
|
||||
# ---------------------------------------------------------------------------
|
||||
ENTER_CODE = "<code>"
|
||||
END_CODE = "</code>"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jinja2 template
|
||||
# ---------------------------------------------------------------------------
|
||||
# ``enter_code`` / ``end_code`` are the block delimiters the model must wrap
|
||||
# every tool call in. ``tools`` is a list of FunctionTool instances whose
|
||||
# ``.to_code_prompt()`` is called inside the template.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TOOL_PROMPT_TEMPLATE = Template(
|
||||
"""\
|
||||
Available tools:
|
||||
|
||||
{% for tool in tools %}
|
||||
{{ tool.to_code_prompt() }}
|
||||
|
||||
{% endfor %}
|
||||
To call a tool, put exactly one named-argument function call inside {{ enter_code }}...{{ end_code }}:
|
||||
{{ enter_code }}function_name(required_arg='value'){{ end_code }}
|
||||
|
||||
Rules:
|
||||
- You may say one brief natural sentence before the tool call; for slow information tools, briefly say that you will check.
|
||||
- For expression/background tools, always speak first. For requested expressions, use a short pattern like "Sure, here's my best <emotion>."; otherwise use a fitting empathetic sentence.
|
||||
- Do not mention tags, functions, or tools. Keep prose outside tags brief, and do not claim tool results before a tool result is available.
|
||||
- Use named arguments only; quote strings. Omit optional args instead of placeholder values like "random", "none", "", or null.
|
||||
- Only one tool call may appear in a response.\
|
||||
""",
|
||||
keep_trailing_newline=True,
|
||||
)
|
||||
|
||||
# Text-channel variant: same call format and structural rules, without the
|
||||
# voice-specific "speak first" prose.
|
||||
TEXT_TOOL_PROMPT_TEMPLATE = Template(
|
||||
"""\
|
||||
Available tools:
|
||||
|
||||
{% for tool in tools %}
|
||||
{{ tool.to_code_prompt() }}
|
||||
|
||||
{% endfor %}
|
||||
To call a tool, put exactly one named-argument function call inside {{ enter_code }}...{{ end_code }}:
|
||||
{{ enter_code }}function_name(required_arg='value'){{ end_code }}
|
||||
|
||||
Rules:
|
||||
- Call a tool directly when it helps fulfill the request; no preamble sentence is required.
|
||||
- Do not mention tags, functions, or tools in your prose, and do not claim tool results before a tool result is available.
|
||||
- Use named arguments only; quote strings. Omit optional args instead of placeholder values like "random", "none", "", or null.
|
||||
- Only one tool call may appear in a response.\
|
||||
""",
|
||||
keep_trailing_newline=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_tool_system_prompt(
|
||||
tools: list[FunctionTool],
|
||||
enter_code: str = ENTER_CODE,
|
||||
end_code: str = END_CODE,
|
||||
*,
|
||||
text_only: bool = False,
|
||||
) -> str:
|
||||
"""Render the tool-calling system-prompt section.
|
||||
|
||||
Returns an empty string when *tools* is empty so it can be
|
||||
unconditionally appended to a base system prompt. When *text_only* is set,
|
||||
the written-channel variant is used (no voice "speak first" prose).
|
||||
"""
|
||||
if not tools:
|
||||
return ""
|
||||
|
||||
template = TEXT_TOOL_PROMPT_TEMPLATE if text_only else TOOL_PROMPT_TEMPLATE
|
||||
return template.render(
|
||||
tools=tools,
|
||||
enter_code=enter_code,
|
||||
end_code=end_code,
|
||||
)
|
||||
|
||||
|
||||
def build_block_regex(
|
||||
enter_code: str = ENTER_CODE,
|
||||
end_code: str = END_CODE,
|
||||
) -> str:
|
||||
"""Build a regex that matches a single code block (non-greedy).
|
||||
|
||||
>>> build_block_regex("<code>", "</code>")
|
||||
'<code>.*?</code>'
|
||||
"""
|
||||
return f"{re.escape(enter_code)}.*?{re.escape(end_code)}"
|
||||
Reference in New Issue
Block a user