JuliusBrussee/caveman · error · ValueError

Native executor registration changed

Error message

Native executor registration changed

What it means

On each selected() call the executor re-checks that its tool list still registers identically (via registered()). If the tool set or invocation signature changed since construction, ValueError is raised because the cached recovery binding no longer matches the live tools.

Solutions

  1. Keep the tools list immutable after registration; build a new executor when tools change
  2. Pass a copy (tuple(tools)) so accidental external mutation is detected rather than corrupting state
  3. Re-create the wrapped LLM/executor with the updated tool list instead of editing the old one

Example fix

// before
tools.append(new_tool)  # then reuse existing executor
// after
executor = make_executor(runtime, scope, tools + [new_tool])
Defensive patterns

Strategy: validation

Validate before calling

snapshot = tuple(tools)
# before each dispatch
if snapshot != tuple(tools):
    executor = rebuild_executor(runtime, scope, tools)

Type guard

def registration_stable(executor, tools) -> bool:
    return tuple(executor.tools) == tuple(tools)

Try / catch

try:
    tool = executor.selected(call)
except ValueError as e:
    executor = rebuild_executor(runtime, scope, current_tools)
    tool = executor.selected(call)

Prevention

When it happens

Trigger: Mutating the tools tuple/list after constructing the executor (adding/removing tools, changing names) and then dispatching a ToolSelection through selected().

Common situations: Dynamic tool registries that add tools mid-session, code that mutates shared tool lists between turns, or replacing a tool's metadata name in place.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/a6afdffe8f52a724. Report an issue: GitHub.

Appendix: source

Thrown at packages/middleware/python/caveman_middleware/llama_index.py:179

    def registered(self, tools, invocation):
        return bool(self.binding is not None and invocation is not None and invocation.registration is self
            and invocation.recovery_allowed and len(tools) == len(self.tools)
            and all(left is right for left, right in zip(tools, self.tools))
            and tuple(tool.metadata.name for tool in tools) == self.names
            and self.runtime.owns_binding(self.binding, self.scope)
            and self.runtime.owns_binding(self.async_binding, self.scope)
            and self.tool.fn is self.sync and self.tool.async_fn is self.async_
            and self.tool.metadata is self.metadata and not self.tool.partial_params and not self.tool.requires_context
            and self.metadata.description == RECOVERY_DESCRIPTION and not self.metadata.return_direct
            and self.metadata.get_parameters_dict() == RECOVERY_SCHEMA)

    def invocation(self):
        return _Invocation(self, self.scope, self.tools, self.successful, True)

    def selected(self, call: ToolSelection):
        if self.binding is not None and not self.registered(self.tools, self.invocation()):
            raise ValueError("Native executor registration changed")
        if not isinstance(call, ToolSelection) or not isinstance(call.tool_id, str) or not call.tool_id or not isinstance(call.tool_kwargs, dict):
            raise TypeError("Expected a native ToolSelection")
        matches = [tool for tool in self.tools if tool.metadata.name == call.tool_name]
        if len(matches) != 1:
            raise ValueError("Native tool is not registered")
        self.successful.pop(call.tool_id, None)
        return matches[0]

    def completed(self, call, result):
        if not isinstance(result, ToolOutput):
            raise TypeError("Native FunctionTool returned an unexpected output")
        if not result.is_error and call.tool_name != "caveman_retrieve" and len(self.successful) < 4096:
            self.successful[call.tool_id] = call.tool_name
        return result


@dataclass(frozen=True)
class CavemanLLMTools:

View on GitHub (pinned to 3ee70a1026)