Skip to content

Tools POC#29

Open
eldadfux wants to merge 5 commits intomainfrom
feat-tools
Open

Tools POC#29
eldadfux wants to merge 5 commits intomainfrom
feat-tools

Conversation

@eldadfux
Copy link
Copy Markdown
Member

No description provided.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 29, 2026

Greptile Summary

This PR introduces a tool-calling (function-calling) system into the utopia-php/agents library. It adds Tool and ToolCall value objects, extends Agent with tool registration and invocation, updates Conversation::send() with an automatic model→tool→model loop, and teaches the OpenAI adapter how to serialise tools into requests and deserialise tool-call responses. The overall design is clean and fits naturally into the existing conversation flow.

Two P1 bugs need to be resolved before merging:

  • decodeToolArguments rejects empty-object argumentsjson_decode('{}', true) returns [] in PHP, and array_is_list([]) is true, so any zero-parameter tool called by the model throws \\InvalidArgumentException. This will silently break every tool with no parameters.
  • Partial tool-batch failure leaves the conversation inconsistent — If the model requests multiple tool calls in one turn and any tool throws, the catch re-throws immediately, leaving subsequent tool-result messages un-appended. The resulting conversation has mismatched tool_call_id entries that the OpenAI API will reject.

Two P2 issues are also worth addressing:

  • extractMessageContent throws on absent content key — some API responses may omit content when only tool_calls are present; treating absence as an empty string is safer.
  • maxToolIterations enforcement — tools are executed on the final allowed iteration but the model is never called again to consume those results, leaving orphaned tool-result messages before the RuntimeException is thrown.
  • The dual short-form/long-form method aliases in both Agent and Tool should be rationalised to a single canonical API before this feature ships.

Confidence Score: 3/5

Not safe to merge — two P1 bugs will cause runtime failures for zero-parameter tools and multi-tool-call batches

Two P1 logic bugs in Conversation.php will cause concrete failures in real usage: zero-parameter tools always throw due to the array_is_list([]) check on json_decode('{}'), and partial tool-batch failures corrupt the conversation state by leaving unmatched tool-result messages. These are not speculative — they will fire on the happy path for common tool patterns.

src/Agents/Conversation.php (both P1 bugs reside here)

Important Files Changed

Filename Overview
src/Agents/Conversation.php Adds the tool-calling loop, token-delta tracking, and executeToolCalls; contains two P1 bugs: empty-object arguments decoded as list break zero-parameter tools, and partial tool-batch failures leave the conversation in an inconsistent state
src/Agents/Tool.php New class for defining callable tools with auto-inferred JSON Schema and runtime argument validation; logic is solid but exposes both short-form and long-form aliases, doubling the API surface
src/Agents/ToolCall.php New value-object representing a single model-requested tool invocation with status tracking; straightforward and clean
src/Agents/Adapters/OpenAI.php Adds tool-call serialisation to requests and response parsing; extractMessageContent throws instead of returning empty string when the content key is absent in a tool-call-only response
src/Agents/Agent.php Adds tool registration/lookup/invocation API; implementation is correct but introduces redundant alias methods alongside the canonical long-form methods
src/Agents/Message.php Extended with toolCalls, toolCallId, and toolName fields; clean additions with proper validation
src/Agents/Adapter.php Adds default supportsTools() returning false; safe opt-in capability check for adapters
tests/Agents/ConversationToolsTest.php New tests for tool-calling loop and tool-error propagation; does not exercise zero-parameter tools or multi-tool-call batches with partial failures

Comments Outside Diff (1)

  1. src/Agents/Agent.php, line 387-423 (link)

    P2 Duplicated API surface via short-form aliases

    Agent now exposes two parallel APIs for the same operations — the canonical long-form methods (addTool, removeTool, getTools, getTool, callTool) and short-form aliases (tool, drop, tools, find, call). Similarly, Tool has both getName/getDescription/getParameters and name/desc/schema.

    This doubles the public API footprint and makes it harder to reason about which style to use. For a POC this may be intentional, but the aliases should either be removed, or annotated with @deprecated / @internal so callers know which form to use.

Reviews (1): Last reviewed commit: "POC" | Re-trigger Greptile

Comment on lines +341 to +353
protected function decodeToolArguments(array|string $arguments): array
{
if (is_array($arguments)) {
return $arguments;
}

$decoded = json_decode($arguments, true);
if (! is_array($decoded) || array_is_list($decoded)) {
throw new \InvalidArgumentException('Tool call arguments must decode into an object');
}

/** @var array<string, mixed> $decoded */
return $decoded;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Empty-object arguments decoded as list, breaking zero-parameter tools

json_decode('{}', true) in PHP returns an empty array []. PHP's array_is_list([]) returns true for an empty array, so the guard array_is_list($decoded) fires and throws an \InvalidArgumentException for any tool that the model calls with no arguments (e.g. a zero-parameter callable). This will affect tools with no parameters every single time the model calls them.

Suggested change
protected function decodeToolArguments(array|string $arguments): array
{
if (is_array($arguments)) {
return $arguments;
}
$decoded = json_decode($arguments, true);
if (! is_array($decoded) || array_is_list($decoded)) {
throw new \InvalidArgumentException('Tool call arguments must decode into an object');
}
/** @var array<string, mixed> $decoded */
return $decoded;
protected function decodeToolArguments(array|string $arguments): array
{
if (is_array($arguments)) {
return $arguments;
}
$decoded = json_decode($arguments, true);
if (! is_array($decoded)) {
throw new \InvalidArgumentException('Tool call arguments must decode into an object');
}
if (! empty($decoded) && array_is_list($decoded)) {
throw new \InvalidArgumentException('Tool call arguments must decode into an object');
}
/** @var array<string, mixed> $decoded */
return $decoded;
}

Comment on lines +317 to +334
protected function executeToolCalls(Message $assistantMessage): void
{
foreach ($assistantMessage->getToolCalls() as $toolCall) {
try {
$arguments = $this->decodeToolArguments($toolCall->getArguments());
$result = $this->agent->callTool($toolCall->getName(), $arguments);
$toolCall->markSuccess();
} catch (\Throwable $error) {
$toolCall->markError($error->getMessage());
throw $error;
}

$toolMessage = (new Message($this->normalizeToolResult($result)))
->setToolCallId($toolCall->getId())
->setToolName($toolCall->getName());

$this->message('tool', $toolMessage);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Partial tool-call batch failure leaves conversation in inconsistent state

When the model requests multiple tool calls in a single response (e.g. [A, B, C]) and tool B throws, the catch block re-throws immediately. This means:

  • Tool A's result message is appended to $this->messages
  • Tool C is never executed and its result message is never appended

The conversation history then contains an assistant message referencing tool_call_ids for A, B, and C, but only a tool-result message for A. When the next adapter->send() call is made, the OpenAI API will reject this as a malformed conversation since B's and C's tool_call_id responses are missing.

Consider appending error-result tool messages for failed calls so the conversation stays coherent:

foreach ($assistantMessage->getToolCalls() as $toolCall) {
    try {
        $arguments = $this->decodeToolArguments($toolCall->getArguments());
        $result = $this->agent->callTool($toolCall->getName(), $arguments);
        $toolCall->markSuccess();
        $content = $this->normalizeToolResult($result);
    } catch (\Throwable $error) {
        $toolCall->markError($error->getMessage());
        $content = $error->getMessage(); // surface error to model
    }

    $this->message('tool', (new Message($content))
        ->setToolCallId($toolCall->getId())
        ->setToolName($toolCall->getName()));
}

Comment on lines +103 to +128
$iterations = 0;
do {
$message = $adapter->send($this->messages, $this->listener);
$this->countAdapterTokenDeltas(
$previousInputTokens,
$previousOutputTokens,
$previousCacheCreationInputTokens,
$previousCacheReadInputTokens
);

$from = new Assistant($adapter->getModel(), 'Assistant');
$this->message($from, $message);

if (! $message->hasToolCalls()) {
return $message;
}

$this->countInputTokens($this->agent->getAdapter()->getInputTokens());
$this->countOutputTokens($this->agent->getAdapter()->getOutputTokens());
$this->countCacheCreationInputTokens($this->agent->getAdapter()->getCacheCreationInputTokens());
$this->countCacheReadInputTokens($this->agent->getAdapter()->getCacheReadInputTokens());
if (! $adapter->supportsTools()) {
throw new \Exception('Tool calls are not supported for this adapter');
}

$from = new Assistant($this->agent->getAdapter()->getModel(), 'Assistant');
$this->message($from, $message);
$this->executeToolCalls($message);
$iterations++;
} while ($iterations < $this->maxToolIterations);

return $message;
throw new \RuntimeException('Tool-calling loop exceeded max iterations');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Last allowed iteration executes tools whose results are never consumed

With the current do-while structure, when iterations reaches maxToolIterations - 1 inside the loop body, tools are still executed and their result messages are appended to $this->messages before the loop condition is checked and found false. The RuntimeException is then thrown, but the conversation already contains orphaned tool-result messages with no subsequent model response.

If a caller catches RuntimeException and calls send() again, those dangling tool messages will be re-sent to the API — the model will see tool results without a preceding assistant tool-call message, which is invalid for the OpenAI API.

Consider checking the limit before executing tool calls:

$this->executeToolCalls($message);
$iterations++;

if ($iterations >= $this->maxToolIterations) {
    throw new \RuntimeException('Tool-calling loop exceeded max iterations');
}

Comment on lines 476 to +488
return $parts;
}

/**
* @param array<string, mixed> $message
*
* @throws \Exception
*/
protected function extractMessageContent(array $message): string
{
if (! array_key_exists('content', $message)) {
throw new \Exception('Invalid response format received from the API');
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 content absence throws instead of returning empty string

When OpenAI returns a response that has only tool_calls and no content key (some API versions may omit it entirely rather than setting it to null), extractMessageContent throws 'Invalid response format received from the API'. Since extractToolCalls already handles the tool_calls field independently, treating an absent content as an empty string is safer and consistent with how null is already handled:

Suggested change
return $parts;
}
/**
* @param array<string, mixed> $message
*
* @throws \Exception
*/
protected function extractMessageContent(array $message): string
{
if (! array_key_exists('content', $message)) {
throw new \Exception('Invalid response format received from the API');
}
protected function extractMessageContent(array $message): string
{
if (! array_key_exists('content', $message)) {
return '';
}
$content = $message['content'];
if (is_string($content)) {
return $content;
}
if ($content === null) {
return '';
}
if (! is_array($content)) {
throw new \Exception('Invalid response format received from the API');
}

- Removed the `supportsTools` method from the Adapter class and related logic in the OpenAI adapter to simplify tool management.
- Updated the Agent and Tool classes to eliminate unnecessary schema parameters, ensuring a cleaner interface.
- Introduced new methods in the Conversation class to manage tool protocol interactions, including seeding and parsing tool protocol responses.
- Enhanced tests to validate tool protocol functionality across different adapters, ensuring robust handling of tool calls and responses.
- Added tool name alias resolution in the Agent class to allow for more flexible tool calls.
- Introduced new methods in the Conversation class to manage tool protocol streaming, including state management and chunk processing.
- Updated tests to validate the new tool resolution feature and ensure proper streaming behavior across different scenarios.
- Added a runtime exception for invalid tool protocol responses in the Conversation class to ensure strict adherence to expected message formats.
- Removed deprecated methods related to loose final payload decoding and fenced JSON unwrapping to streamline the codebase.
- Updated tool schemas to include detailed descriptions for better clarity on expected properties.
- Introduced a new test case to validate strict protocol enforcement, ensuring that mixed type and name envelopes are correctly rejected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant