Conversation
Greptile SummaryThis PR introduces a tool-calling (function-calling) system into the Two P1 bugs need to be resolved before merging:
Two P2 issues are also worth addressing:
Confidence Score: 3/5Not 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
|
| 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; |
There was a problem hiding this comment.
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.
| 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; | |
| } |
| 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); | ||
| } |
There was a problem hiding this comment.
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()));
}| $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'); |
There was a problem hiding this comment.
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');
}| 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'); | ||
| } |
There was a problem hiding this comment.
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:
| 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.
No description provided.