assistant-engine/open-functions-core
Open Functions Core provides foundational PHP/Laravel abstractions for defining, registering, and executing Open Functions. It includes schemas, registries, and tool-calling primitives so applications can expose callable tools to AI assistants and agent workflows consistently across PHP frameworks.
assistant-engine/open-functions-core is a low-level building block, not a full AI SDK. It gives you three things: a base class for defining tools the LLM can call (AbstractOpenFunction), a registry that aggregates many functions and emits OpenAI-compatible JSON schema (FunctionRegistry), and a dispatcher that routes an incoming tool_call back to your PHP code. If you already talk to OpenAI (or Anthropic, or Ollama) directly, this package sits between your Http::post('.../chat/completions') call and your business logic.
Install and orient yourself
composer require assistant-engine/open-functions-core
There is no published config or service provider — it's a pure library. That's the first thing to internalize. Don't look for config/open-functions.php; it doesn't exist. The files worth reading first, in order:
src/Contracts/AbstractOpenFunction.php — the two methods you override: generateFunctionDefinitions(): array and callFunction(string $name, array $arguments): mixed.src/Models/Registry/FunctionRegistry.php — register(), generateFunctionDefinitions(), callFunction(), and the has() / names() helpers.src/Models/Messages/* — lightweight message DTOs (AssistantMessage, ToolMessage, UserMessage) if you want the package to also serialize conversation turns.First use case — a single database lookup tool
The fastest way to feel productive is one function that reads from your own models, then wire it into a single chat call.
namespace App\Ai\Functions;
use AssistantEngine\OpenFunctions\Core\Contracts\AbstractOpenFunction;
class OrderLookupFunction extends AbstractOpenFunction
{
public function generateFunctionDefinitions(): array
{
return [[
'name' => 'lookup_order',
'description' => 'Look up an order by its public reference number.',
'parameters' => [
'type' => 'object',
'properties' => [
'reference' => [
'type' => 'string',
'description' => 'Order reference, e.g. ORD-10293.',
],
],
'required' => ['reference'],
],
]];
}
public function callFunction(string $name, array $arguments): mixed
{
return match ($name) {
'lookup_order' => Order::where('reference', $arguments['reference'])
->firstOrFail()
->only(['status', 'total', 'shipped_at']),
default => throw new \InvalidArgumentException("Unknown function {$name}"),
};
}
}
Register and call it:
$registry = new FunctionRegistry();
$registry->register(new OrderLookupFunction());
$response = Http::withToken(config('services.openai.key'))
->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4o-mini',
'messages' => [['role' => 'user', 'content' => 'Where is ORD-10293?']],
'tools' => $registry->generateFunctionDefinitions(),
])
->json();
$call = $response['choices'][0]['message']['tool_calls'][0] ?? null;
if ($call) {
$result = $registry->callFunction(
$call['function']['name'],
json_decode($call['function']['arguments'], true)
);
}
That's the entire mental model: definitions out, tool calls in, registry dispatches.
One function class per domain, not per endpoint. The registry's generateFunctionDefinitions() flattens everything you register into a single tools array. Resist the urge to make one class per verb. A CustomersFunction exposing find_customer, list_orders, and create_ticket keeps your OpenAI payload small and lets the LLM reason across related operations. The name in each definition should be globally unique across the registry because callFunction() is a single dispatch surface — the package does no namespacing.
Bind the registry in a service provider. You'll almost always want a singleton (or scoped) registry that's assembled once and reused across controllers, jobs, and console commands.
// app/Providers/AppServiceProvider.php
$this->app->singleton(FunctionRegistry::class, function () {
$registry = new FunctionRegistry();
foreach (config('ai.functions', []) as $class) {
$registry->register(app($class));
}
return $registry;
});
// config/ai.php
'functions' => [
App\Ai\Functions\OrderLookupFunction::class,
App\Ai\Functions\SupportTicketFunction::class,
App\Ai\Functions\ProductSearchFunction::class,
],
Because functions are resolved through the container, they can inject repositories, HTTP clients, and the current Request — which matters for tenant scoping.
Drive a multi-turn tool loop, not a single hop. Real assistants need to run the tool, feed the result back, and let the model speak. The shape is always: call → tool_calls present? → execute → append a role: "tool" message → call again.
$messages = [['role' => 'user', 'content' => $prompt]];
for ($i = 0; $i < 5; $i++) {
$data = Http::withToken($token)->post(/* ... */ ['messages' => $messages, 'tools' => $defs])->json();
$message = $data['choices'][0]['message'];
if (empty($message['tool_calls'])) {
return $message['content'];
}
$messages[] = $message;
foreach ($message['tool_calls'] as $call) {
$messages[] = [
'role' => 'tool',
'tool_call_id' => $call['id'],
'content' => json_encode(
$registry->callFunction($call['function']['name'], json_decode($call['function']['arguments'], true))
),
];
}
}
Cap the loop. Models will occasionally keep calling tools; a hard for limit prevents infinite spend.
Keep tool results JSON-serializable and small. callFunction() returns mixed, and you serialize it into the tool message. Eloquent collections serialize fine but can dump 40KB of JSON into context. Always ->only([...]), ->map(...), or ->toArray() on a trimmed projection. If a tool returns "I found 300 orders," the LLM will try to summarize all of them and blow your context window on the next turn.
Scope tools per user, per conversation, per request. The registry is mutable — you don't have to register everything globally. A common pattern is an "admin" and "customer" registry, or dynamically registering functions based on the authenticated user's plan:
$registry = app(FunctionRegistry::class)->fresh(); // or new
if ($user->isAdmin()) {
$registry->register(app(BillingFunction::class));
}
This is far cleaner than giving every function an authorization check inside callFunction(), because it also prevents the model from seeing tools it can't use. An LLM that sees a delete_account tool but always gets 403s will hallucinate workarounds.
Queue long-running tools. If a function hits a slow API or does heavy DB work, the synchronous loop above will time out. Two viable patterns: (a) make the tool return a "pending" marker and have the UI poll; (b) split the flow — first HTTP call classifies intent, dispatch a job, then resume with a second call once the job completes. The registry itself is synchronous; it does zero scheduling for you.
Test with a fake registry. Unit tests should not touch OpenAI. Test generateFunctionDefinitions() returns the exact schema you expect (schema drift breaks the LLM), and test callFunction() directly with fixture arguments. For full controller tests, bind a stub registry in the container and assert it was called with the expected name and arguments.
Schema drift is silent and deadly. The most common bug is a mismatch between what the model sends and what your function expects. If a parameter is "required" => ["reference"] but your callFunction() assumes $arguments['reference'] exists, you'll get an undefined key when the model omits it (which it will, under pressure, with weak models). Validate defensively:
public function callFunction(string $name, array $arguments): mixed
{
$validated = validator($arguments, [
'reference' => 'required|string|max:64',
])->validate();
// ...
}
Treat the LLM as an untrusted input source — because functionally, it is.
function vs function_call is a provider quirk. The package emits a plain array of function definitions, not provider-specific wrappers. You still need to wrap them per endpoint: OpenAI expects tools: [{type: "function", function: {...}}], Anthropic uses tools: [{name, description, input_schema}], and older OpenAI models use functions: with function_call. Check what shape your target API wants before assuming generateFunctionDefinitions() output can be dropped in verbatim — the flat structure it returns usually needs one array_map to rebrand parameters as input_schema, etc.
No built-in error handling in the dispatch loop. If callFunction() throws, the exception propagates into your controller and 500s the request. But the model has no idea the tool failed — and if you catch the exception and return null, the model will treat it as "the order doesn't exist," which is usually wrong. The right pattern is to catch, return a structured error, and let the model recover:
try {
$result = $registry->callFunction($name, $args);
} catch (\Throwable $e) {
Log::warning('tool_failed', ['name' => $name, 'args' => $args, 'message' => $e->getMessage()]);
$result = ['error' => $e->getMessage()];
}
Log the raw arguments before dispatch — this is your only reliable way to debug "why did the model call lookup_order with {"reference": "my order"}" after the fact.
Descriptions are prompts. The description field is not documentation, it's a prompt fragment. Vague descriptions ("Look up orders") cause the model to call the tool for irrelevant queries. Be specific: "Look up an order by its reference number. Only use this when the customer has given an exact ORD-XXXXX style reference." Add negative examples. It's genuinely the highest-leverage lever you have.
Token cost compounds in the loop. Every tool call adds the full function definitions, the assistant message, and the tool result back into the next call. A five-tool registry with verbose descriptions can be 2,000 tokens per turn before the conversation even starts. Keep descriptions tight, trim tool results aggressively, and consider a two-model approach: a cheap model for tool selection, the expensive one only for the final synthesis.
Extension points are what you make of them. The base class is thin, so the idiomatic extension pattern is a custom abstract class layering on your own needs — auth scoping, logging, argument validation, rate limiting:
abstract class AppOpenFunction extends AbstractOpenFunction
{
public function callFunction(string $name, array $arguments): mixed
{
$this->authorize($name);
$result = $this->handle($name, $arguments);
Log::info('ai.tool', ['name' => $name, 'result_keys' => array_keys((array) $result)]);
return $result;
}
abstract protected function handle(string $name, array $arguments): mixed;
}
Everything else in the package is a value object or an array shape. There's no event bus, no middleware pipeline, no hooks to discover. Don't hunt for them.
Debugging without visibility is painful. The package gives you zero observability. Add a php artisan ai:tools:list command early that dumps generateFunctionDefinitions() as pretty JSON — it's the fastest way to spot malformed schemas, duplicate names, or a class that failed to register because a constructor dependency was missing. Duplicate function names silently overwrite in the registry, so a "tool not being called" bug is often a "tool was shadowed by another instance" bug.
Version pinning matters. At 0 stars and 0 dependents, this is a young library. Treat semver bumps as potentially breaking even on minor versions; run the full test suite that exercises generateFunctionDefinitions() output and callFunction() dispatch after every composer update. If you're on it, you're on it early — and you're the one who'll find the rough edges.
How can I help you explore Laravel packages today?