Skip to content

Trustcall do not strip Injected arguments from BaseTool #54

@Dimitri-Accad

Description

@Dimitri-Accad

Hello

I noticed that the Injected arguments do not get stripped before trustcall verification causing verification issues.

For example using an InjectedState, InjectedToolCallId or InjectedToolArgs in the tool arguments will trigger a verification of these fields even if parsed as Optional.

Here is the example of the handoff_tool from langgraph documentation:

def create_handoff_tool(*, agent_name: str, description: str | None = None):
    name = f"transfer_to_{agent_name}"
    description = description or f"Transfer to {agent_name}"

    @tool(name, description=description)
    def handoff_tool(
        state: Optional[Annotated[AgentState, InjectedState]] = None,
        tool_call_id: Optional[Annotated[str, InjectedToolCallId]] = None,
    ) -> Command:
        tool_message = {
            "role": "tool",
            "content": f"Successfully transferred to {agent_name}",
            "name": name,
            "tool_call_id": tool_call_id,
        }
        return Command(
            goto=agent_name,
            update={"messages": state["messages"] + [tool_message]},
            graph=Command.PARENT,
        )

    return handoff_tool

The (truncated) error it produces:

"Error:\n\n\nExpected either a dictionary with a 'type' key or an object with a 'type' attribute. Instead got type <class 'dict'>.\n\nExpected Parameter Schema:\n\n```json\n{'$defs': {'AIMessage': {'additionalProperties': True, 'description': 'Message from an AI.....

In other tools, injecting the state is also problematic but this time it can be avoided by marking it as Optional:

@tool("SomeTool")
def some_tool(
    some_argument: Type,
    state: Optional[
        Annotated[AgentState, InjectedState]
    ] = None,  # Optional or else trustcall will try to generate it itself
) -> Dict[str, Any]:

I have noticed that in _base.py this snippet of code that seems responsible for stripping the injected tool arguments is not called for both my tools mentioned above:

def csff_(function: Callable) -> Type[BaseModel]:
    fn = _strip_injected(function)
    schema = create_schema_from_function(function.__name__, fn)
    schema.__name__ = function.__name__
    return schema

used there:

def ensure_tools(
    tools: Sequence[TOOL_T],
) -> List[Union[BaseTool, Type[BaseModel], Callable]]:
    results: list = []
    for t in tools:
        if isinstance(t, dict):
            print(f"DEBUG: dict: {t}")
            if all(k in t for k in ("name", "description", "parameters")):
                schema = create_model_from_schema(
                    {"title": t["name"], **t["parameters"]}
                )
                schema.__doc__ = (getattr(schema, __doc__, "") or "") + (
                    t.get("description") or ""
                )
                schema.__name__ = t["name"]
                results.append(schema)
            elif all(k in t for k in ("type", "function")):
                # Already in openai format
                resolved = ensure_tools([t["function"]])
                results.extend(resolved)
            else:
                model = create_model_from_schema(t)
                if not model.__doc__:
                    model.__doc__ = t.get("description") or model.__name__
                results.append(model)
        elif is_typeddict(t):
            results.append(_convert_any_typed_dicts_to_pydantic(cast(type, t)))
        elif isinstance(t, (BaseTool, type)):
            # All my tools end up here and are not stripped of their injected args    <-----------------------
            results.append(t)
        elif callable(t):
            # This section calls the stripping function   <-----------------------
            results.append(csff_(t))
        else:
            raise ValueError(f"Invalid tool type: {type(t)}")
    return list(results)

I use trustcall using the create_extractor method:

bound_decision_llm = create_extractor(
    llm,
    tools=[some_tool, transfer_to_call_model],
    tool_choice="any",
)

Do you have any insights on this behavior? Is it a bug or am I missing something in my implementation?

Best regards,
Dimitri

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions