""" Action discovery, validation, and dispatch — the built-in twin of plugin_loader. Every actions/*.py that exposes a module-level ``TOOL`` dict is auto-discovered here, exactly like a drop-in plugin, so main.py never has to hardcode a tool declaration or a dispatch branch for it. Adding a new bundled action is then the same one-file operation as writing a plugin: define ``TOOL`` and a handler. ``TOOL`` shape (see actions/open_app.py for a live example): TOOL = { "name": "open_app", # unique, ^[a-zA-Z_][a-zA-Z0-9_]{0,63}$ "description": "...", # what Gemini reads to route the call "parameters": {"type": "OBJECT", ...}, # Gemini function-declaration schema "handler": open_app, # the callable to run } The handler is invoked through signature introspection: it receives ``parameters`` plus whichever of ``player`` / ``speak`` / ``response`` / ``session_memory`` it actually declares — so existing action signatures work unchanged. Discovery runs once at startup; import errors, validation errors, and name collisions are logged and the offending file is skipped — they NEVER raise out of discover_actions() and never abort the scan of the remaining files. """ from __future__ import annotations import importlib.util import inspect import re import sys import traceback from dataclasses import dataclass, field from pathlib import Path from typing import Callable, Optional _NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$") _DEFAULT_PARAMS = {"type": "OBJECT", "properties": {}} _CTX_KEYS = ("player", "speak", "response", "session_memory") # A tool may declare that the model should NOT be held up waiting for it. # `behavior` goes to the API with the declaration; `scheduling` decides when the # eventual result is allowed back into the conversation: # WHEN_IDLE — wait for a gap in the speech (the sane default) # SILENT — record it, do not prompt a reply (the tool already announced) # INTERRUPT — cut in immediately (only when the answer cannot wait) _BEHAVIORS = ("BLOCKING", "NON_BLOCKING") _SCHEDULING = ("WHEN_IDLE", "SILENT", "INTERRUPT") def _opt_upper(value, allowed: tuple[str, ...]) -> Optional[str]: v = str(value or "").strip().upper() return v if v in allowed else None @dataclass class ActionRecord: name: str description: str = "" parameters: dict = field(default_factory=lambda: dict(_DEFAULT_PARAMS)) handler: Optional[Callable] = None file: str = "" valid: bool = False error: str = "" behavior: Optional[str] = None # None = the API's default (blocking) scheduling: Optional[str] = None # None = the API's default (WHEN_IDLE) class ActionRegistry: def __init__(self, actions: dict[str, ActionRecord], logger: Callable[[str], None]): self._actions = actions # name -> ActionRecord, VALID entries only self._all_records: list[ActionRecord] = [] self._logger = logger # -- called by main.py at LiveConnectConfig build time -- def get_tool_declarations(self) -> list[dict]: out = [] for rec in self._actions.values(): decl = {"name": rec.name, "description": rec.description, "parameters": rec.parameters} if rec.behavior: decl["behavior"] = rec.behavior out.append(decl) return out def has(self, name: str) -> bool: return name in self._actions def scheduling(self, name: str) -> Optional[str]: """How this action's result should re-enter the conversation, if it said.""" rec = self._actions.get(name) return rec.scheduling if rec else None def names(self) -> set[str]: return set(self._actions.keys()) # -- called by main.py from _execute_tool -- def run(self, name: str, parameters: dict, ctx: dict | None = None) -> str: rec = self._actions.get(name) if rec is None or not rec.valid: return f"Action '{name}' is not available." try: return _call_handler(rec.handler, parameters, ctx or {}) or "Done." except Exception as e: self._logger(f"Action '{name}' crashed during run(): {e}") traceback.print_exc() return f"Tool '{name}' failed: {e}" def _call_handler(fn: Callable, parameters: dict, ctx: dict) -> str: """Invoke the handler passing only the context kwargs it actually declares (or all of them if it has **kwargs), so each action's existing signature works unchanged.""" sig = inspect.signature(fn) has_var_kw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()) kwargs = {} for key in _CTX_KEYS: if has_var_kw or key in sig.parameters: kwargs[key] = ctx.get(key) return fn(parameters=parameters, **kwargs) def _validate(module, filename: str) -> ActionRecord: """Returns an ActionRecord; .valid=False + .error set on any problem. Never raises.""" tool = getattr(module, "TOOL", None) if not isinstance(tool, dict): return ActionRecord(name=Path(filename).stem, file=filename, error="No module-level TOOL dict (not a discoverable action).") name = tool.get("name") if not isinstance(name, str) or not _NAME_RE.match(name): return ActionRecord(name=str(name or Path(filename).stem), file=filename, error="TOOL['name'] missing or not a valid identifier.") description = tool.get("description") if not isinstance(description, str) or not description.strip(): return ActionRecord(name=name, file=filename, error="TOOL['description'] missing or empty.") parameters = tool.get("parameters", _DEFAULT_PARAMS) if not isinstance(parameters, dict) or parameters.get("type") != "OBJECT": return ActionRecord(name=name, file=filename, error="TOOL['parameters'] must be a dict with \"type\": \"OBJECT\".") handler = tool.get("handler") if not callable(handler): return ActionRecord(name=name, file=filename, error="TOOL['handler'] missing or not callable.") return ActionRecord(name=name, description=description.strip(), parameters=parameters, handler=handler, file=filename, valid=True, error="", behavior=_opt_upper(tool.get("behavior"), _BEHAVIORS), scheduling=_opt_upper(tool.get("scheduling"), _SCHEDULING)) def discover_actions(actions_dir: Path, reserved_names: set[str] | None = None, logger: Callable[[str], None] = print) -> ActionRegistry: """ Scans actions_dir for *.py files (skips files starting with '_'). A file is only treated as an action if it exposes a module-level TOOL dict; files without one (shared helpers, capture-only modules) are silently ignored. Import/validation errors and name collisions are logged and the file is skipped — they NEVER raise out of this function. """ reserved = reserved_names or set() actions_dir.mkdir(parents=True, exist_ok=True) valid: dict[str, ActionRecord] = {} all_records: list[ActionRecord] = [] files = sorted(actions_dir.glob("*.py"), key=lambda p: p.name) # deterministic order for path in files: if path.name.startswith("_"): continue try: module_name = f"actions.{path.stem}" # Reuse the already-imported module when present so handlers are the # same objects the rest of the app holds. module = sys.modules.get(module_name) if module is None: spec = importlib.util.spec_from_file_location(module_name, path) if spec is None or spec.loader is None: raise ImportError("could not build import spec") module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module try: spec.loader.exec_module(module) except Exception: sys.modules.pop(module_name, None) raise if getattr(module, "TOOL", None) is None: continue # not an action file — a helper/capture-only module rec = _validate(module, path.name) if rec.valid and rec.name in reserved: rec = ActionRecord(name=rec.name, file=path.name, error=f"Name '{rec.name}' collides with a reserved core tool — rejected.") elif rec.valid and rec.name in valid: other = valid[rec.name].file rec = ActionRecord(name=rec.name, file=path.name, error=f"Name '{rec.name}' already used by action '{other}' — rejected.") except Exception as e: rec = ActionRecord(name=path.stem, file=path.name, error=f"Failed to load: {e}") traceback.print_exc() all_records.append(rec) if rec.valid: valid[rec.name] = rec logger(f"Action loaded: {rec.name} ({path.name})") else: # Only log a rejection if the file actually tried to be an action. logger(f"Action rejected: {path.name} — {rec.error}") registry = ActionRegistry(valid, logger) registry._all_records = all_records logger(f"Action discovery complete: {len(valid)} active.") return registry