Context
Following up on #45 (Structured Messages) and referencing #147, the next logical primitive for Celeste is First-Class Function Calling.
Currently, using tools requires:
- Manually defining JSON schemas (verbose, error-prone, provider-specific).
- Manually handling the "LLM -> Stop -> Exec -> Result -> LLM" loop in user code.
This proposal aims to solve this by treating Python Callable objects as first-class citizens, leveraging the library's existing Pydantic infrastructure to automate schema generation and execution, without introducing heavy "Agent" abstractions.
Proposed DX
The user should be able to pass standard Python functions (typed) directly to generate().
from typing import Literal
def get_weather(city: str, unit: Literal["c", "f"] = "c") -> str:
"""Get the current weather for a specific city."""
# ... implementation ...
return f"25{unit} in {city}"
# 1. Schema generation is automatic (via Pydantic introspection)
# 2. Execution loop is handled internally (if max_steps > 0)
response = await celeste.text.generate(
"What is the weather in Paris?",
model="gpt-4o",
tools=[get_weather],
max_steps=5 # Enables the recursive execution loop
)
print(response.content)
# Output: "It is 25c in Paris."
Architectural Design
This requires changes in three layers:
1. Schema Generation (src/celeste/utils/tools.py)
We can leverage pydantic.TypeAdapter (already used in structured_outputs.py) to reflect on function signatures and docstrings to generate standard JSON Schemas automatically. No new dependencies required.
2. Parameter Mapping (src/celeste/parameters.py)
We need a unified ToolsMapper. Since providers diverge significantly here:
- OpenAI: Uses
tools array + tool_choice.
- Anthropic: Uses
tools (top-level) + tool_choice (different structure).
- Google: Uses
tools (wrapped in function_declarations).
The ParameterMapper logic I implemented previously handles this perfectly. We just need concrete implementations (OpenAIToolMapper, AnthropicToolMapper) to normalize the generic JSON schema into the vendor-specific payload.
3. The Execution Loop (src/celeste/client.py)
We update _predict (or wrap it) to handle the recursive case:
- Step 0: Generate schema from
tools list.
- Step 1: Call Provider.
- Step 2: Check
finish_reason. If TOOL_CALL:
- Validate arguments against the Python function signature.
- Execute the function (Sync or Async).
- Append result message to history.
- Recurse (decrement
max_steps).
- Step 3: Return final
TextOutput.
Definition of Done
I am happy to take this on as it aligns with the ideas on Message structures. This keeps the library "Primitive-first" while removing the boilerplate of the ReAct loop.
Context
Following up on #45 (Structured Messages) and referencing #147, the next logical primitive for Celeste is First-Class Function Calling.
Currently, using tools requires:
This proposal aims to solve this by treating Python
Callableobjects as first-class citizens, leveraging the library's existing Pydantic infrastructure to automate schema generation and execution, without introducing heavy "Agent" abstractions.Proposed DX
The user should be able to pass standard Python functions (typed) directly to
generate().Architectural Design
This requires changes in three layers:
1. Schema Generation (
src/celeste/utils/tools.py)We can leverage
pydantic.TypeAdapter(already used instructured_outputs.py) to reflect on function signatures and docstrings to generate standard JSON Schemas automatically. No new dependencies required.2. Parameter Mapping (
src/celeste/parameters.py)We need a unified
ToolsMapper. Since providers diverge significantly here:toolsarray +tool_choice.tools(top-level) +tool_choice(different structure).tools(wrapped infunction_declarations).The
ParameterMapperlogic I implemented previously handles this perfectly. We just need concrete implementations (OpenAIToolMapper,AnthropicToolMapper) to normalize the generic JSON schema into the vendor-specific payload.3. The Execution Loop (
src/celeste/client.py)We update
_predict(or wrap it) to handle the recursive case:toolslist.finish_reason. IfTOOL_CALL:max_steps).TextOutput.Definition of Done
function_to_schemautility added.ToolMapperadded toTextParametersand implemented for OpenAI/Anthropic/Gemini.generate()acceptstools: list[Callable]andmax_steps: int.I am happy to take this on as it aligns with the ideas on Message structures. This keeps the library "Primitive-first" while removing the boilerplate of the ReAct loop.