Skip to content

Commit 1585902

Browse files
Merge pull request #9 from carmelosantana/chore_fix-tool-use
Updates Ollama provider with enhanced tool calling
2 parents fd81e5b + 828f2d6 commit 1585902

File tree

4 files changed

+260
-6
lines changed

4 files changed

+260
-6
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "carmelosantana/php-agents",
33
"description": "PHP 8.4+ agent framework — interfaces, tools, and providers for building AI agents",
4-
"version": "0.6.0",
4+
"version": "0.6.1",
55
"type": "library",
66
"license": "MIT",
77
"authors": [

src/Agent/AbstractAgent.php

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -295,19 +295,32 @@ public function run(MessageInterface $input, ?Conversation $history = null): Out
295295
}
296296

297297
/**
298+
* Collect all tools with name-based deduplication (last-registered wins).
299+
*
300+
* Order: standalone tools → toolkit tools → DoneTool.
301+
* If multiple tools share the same name, the later registration
302+
* silently overrides the earlier one. This allows workspace-installed
303+
* toolkit packages to replace core tools.
304+
*
298305
* @return ToolInterface[]
299306
*/
300307
private function allTools(): array
301308
{
302-
$tools = [...$this->tools()];
309+
$indexed = [];
310+
311+
foreach ($this->tools() as $tool) {
312+
$indexed[$tool->name()] = $tool;
313+
}
303314

304315
foreach ($this->toolkits as $toolkit) {
305-
$tools = [...$tools, ...$toolkit->tools()];
316+
foreach ($toolkit->tools() as $tool) {
317+
$indexed[$tool->name()] = $tool;
318+
}
306319
}
307320

308-
$tools[] = DoneTool::create();
321+
$indexed[DoneTool::NAME] = DoneTool::create();
309322

310-
return $tools;
323+
return array_values($indexed);
311324
}
312325

313326
/**

src/Message/AssistantMessage.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public function toArray(): array
5151
'type' => 'function',
5252
'function' => [
5353
'name' => $tc->name,
54-
'arguments' => json_encode($tc->arguments),
54+
'arguments' => json_encode($tc->arguments ?: new \stdClass()),
5555
],
5656
], $this->toolCalls);
5757
}

src/Provider/OllamaProvider.php

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,77 @@
55
namespace CarmeloSantana\PHPAgents\Provider;
66

77
use CarmeloSantana\PHPAgents\Config\ModelDefinition;
8+
use CarmeloSantana\PHPAgents\Contract\ToolInterface;
9+
use CarmeloSantana\PHPAgents\Provider\Response;
810
use Symfony\Contracts\HttpClient\HttpClientInterface;
911

1012
final class OllamaProvider extends OpenAICompatibleProvider
1113
{
14+
/**
15+
* JSON Schema keywords unsupported by Ollama's tool parser.
16+
*
17+
* Ollama's Go backend maps tool schemas to strict internal structs.
18+
* Keywords not in those structs cause 400 errors or silently break
19+
* property parsing. We strip these before sending and demote
20+
* validation constraints into the description field.
21+
*/
22+
private const UNSUPPORTED_SCHEMA_KEYWORDS = [
23+
// Validation keywords
24+
'minimum',
25+
'maximum',
26+
'exclusiveMinimum',
27+
'exclusiveMaximum',
28+
'minLength',
29+
'maxLength',
30+
'pattern',
31+
'additionalProperties',
32+
'minItems',
33+
'maxItems',
34+
'uniqueItems',
35+
'format',
36+
// Structural / logic keywords
37+
'oneOf',
38+
'anyOf',
39+
'allOf',
40+
'const',
41+
'$ref',
42+
'$defs',
43+
'patternProperties',
44+
'default',
45+
];
46+
47+
/**
48+
* Keywords whose values can be demoted into the description field
49+
* so the LLM still sees the constraint as natural language.
50+
*/
51+
private const DEMOTABLE_KEYWORDS = [
52+
'minimum' => 'Minimum value: %s.',
53+
'maximum' => 'Maximum value: %s.',
54+
'exclusiveMinimum' => 'Must be greater than %s.',
55+
'exclusiveMaximum' => 'Must be less than %s.',
56+
'minLength' => 'Minimum length: %s.',
57+
'maxLength' => 'Maximum length: %s.',
58+
'pattern' => 'Must match pattern: %s.',
59+
'minItems' => 'Minimum items: %s.',
60+
'maxItems' => 'Maximum items: %s.',
61+
'const' => 'Must be exactly: %s.',
62+
'default' => 'Default: %s.',
63+
'format' => 'Format: %s.',
64+
];
65+
66+
/**
67+
* Default context window size for Ollama when tools are present.
68+
*
69+
* Tool schemas can easily consume 30-50K tokens. Ollama defaults
70+
* to 8192 which causes silent truncation and 500 errors.
71+
*/
72+
private const DEFAULT_NUM_CTX = 65536;
73+
1274
public function __construct(
1375
string $model = 'llama3.2',
1476
string $baseUrl = 'http://localhost:11434/v1',
1577
?HttpClientInterface $httpClient = null,
78+
private int $numCtx = self::DEFAULT_NUM_CTX,
1679
) {
1780
parent::__construct(
1881
model: $model,
@@ -22,6 +85,28 @@ public function __construct(
2285
);
2386
}
2487

88+
/**
89+
* Override chat to inject Ollama-specific options.
90+
*
91+
* Sets num_ctx to ensure Ollama allocates enough context for tool
92+
* schemas. Without this, Ollama defaults to 8192 tokens and silently
93+
* truncates the prompt, causing corrupted tool definitions and 500s.
94+
*/
95+
#[\Override]
96+
public function chat(array $messages, array $tools = [], array $options = []): Response
97+
{
98+
return parent::chat($messages, $tools, $this->injectOllamaOptions($options, $tools));
99+
}
100+
101+
/**
102+
* Override stream to inject Ollama-specific options.
103+
*/
104+
#[\Override]
105+
public function stream(array $messages, array $tools = [], array $options = []): iterable
106+
{
107+
return parent::stream($messages, $tools, $this->injectOllamaOptions($options, $tools));
108+
}
109+
25110
/**
26111
* List locally available models via Ollama's native API.
27112
*
@@ -89,4 +174,160 @@ public function isAvailable(): bool
89174
return false;
90175
}
91176
}
177+
178+
/**
179+
* Inject Ollama-specific options into the request payload.
180+
*
181+
* When tools are present, sets num_ctx to ensure Ollama allocates
182+
* enough KV cache for the full tool schema payload. Without this,
183+
* Ollama's default 8192 tokens truncates tool definitions mid-schema,
184+
* causing the model to generate invalid tool calls → 500 errors.
185+
*
186+
* @param array<string, mixed> $options
187+
* @param array<ToolInterface> $tools
188+
* @return array<string, mixed>
189+
*/
190+
private function injectOllamaOptions(array $options, array $tools): array
191+
{
192+
if (!empty($tools) && !isset($options['num_ctx'])) {
193+
$options['num_ctx'] = $this->numCtx;
194+
}
195+
196+
return $options;
197+
}
198+
199+
/**
200+
* Format tools for Ollama, stripping unsupported JSON Schema keywords.
201+
*
202+
* Ollama's tool schema parser only supports a subset of JSON Schema.
203+
* We generate full schemas via toFunctionSchema() then recursively
204+
* remove keywords that would cause 400 "invalid tool call arguments".
205+
*/
206+
#[\Override]
207+
protected function formatTools(array $tools): array
208+
{
209+
return array_map(function (ToolInterface $tool): array {
210+
$schema = $tool->toFunctionSchema();
211+
212+
if (isset($schema['function']['parameters'])) {
213+
$schema['function']['parameters'] = $this->sanitizeSchema(
214+
$schema['function']['parameters'],
215+
);
216+
}
217+
218+
return $schema;
219+
}, $tools);
220+
}
221+
222+
/**
223+
* Recursively sanitize a schema node for Ollama compatibility.
224+
*
225+
* Demotes validation constraints into the description field,
226+
* flattens union combinators to the first concrete type, then
227+
* strips all remaining unsupported keywords.
228+
*
229+
* @param array<string, mixed> $schema
230+
* @return array<string, mixed>
231+
*/
232+
private function sanitizeSchema(array $schema): array
233+
{
234+
// Flatten anyOf / oneOf / allOf → pick first non-null type
235+
foreach (['anyOf', 'oneOf', 'allOf'] as $combinator) {
236+
if (isset($schema[$combinator]) && is_array($schema[$combinator])) {
237+
$schema = $this->flattenCombinator($schema, $combinator);
238+
}
239+
}
240+
241+
// Demote validation keywords into description
242+
$schema = $this->demoteConstraints($schema);
243+
244+
// Strip everything Ollama doesn't understand
245+
foreach (self::UNSUPPORTED_SCHEMA_KEYWORDS as $keyword) {
246+
unset($schema[$keyword]);
247+
}
248+
249+
// Recurse into object properties
250+
if (isset($schema['properties']) && is_array($schema['properties'])) {
251+
foreach ($schema['properties'] as $key => $property) {
252+
if (is_array($property)) {
253+
$schema['properties'][$key] = $this->sanitizeSchema($property);
254+
}
255+
}
256+
}
257+
258+
// Recurse into array items
259+
if (isset($schema['items']) && is_array($schema['items'])) {
260+
$schema['items'] = $this->sanitizeSchema($schema['items']);
261+
}
262+
263+
return $schema;
264+
}
265+
266+
/**
267+
* Flatten a union combinator (anyOf/oneOf/allOf) into a single type.
268+
*
269+
* Picks the first non-null variant and merges its fields into the
270+
* parent schema, so Ollama sees a simple single-type property.
271+
*
272+
* @param array<string, mixed> $schema
273+
* @return array<string, mixed>
274+
*/
275+
private function flattenCombinator(array $schema, string $combinator): array
276+
{
277+
/** @var list<array<string, mixed>> $variants */
278+
$variants = $schema[$combinator];
279+
unset($schema[$combinator]);
280+
281+
// Find the first variant that isn't just {type: "null"}
282+
foreach ($variants as $variant) {
283+
if (!is_array($variant)) {
284+
continue;
285+
}
286+
if (($variant['type'] ?? null) === 'null') {
287+
continue;
288+
}
289+
// Merge variant fields into parent (type, description, etc.)
290+
$schema = array_merge($schema, $variant);
291+
return $schema;
292+
}
293+
294+
// All variants were null — fall back to string
295+
$schema['type'] = 'string';
296+
297+
return $schema;
298+
}
299+
300+
/**
301+
* Demote validation constraints into the description field.
302+
*
303+
* Before stripping unsupported keywords, append their values as
304+
* human-readable hints so the LLM still respects the constraints.
305+
*
306+
* @param array<string, mixed> $schema
307+
* @return array<string, mixed>
308+
*/
309+
private function demoteConstraints(array $schema): array
310+
{
311+
$hints = [];
312+
313+
foreach (self::DEMOTABLE_KEYWORDS as $keyword => $template) {
314+
if (!isset($schema[$keyword])) {
315+
continue;
316+
}
317+
318+
$value = $schema[$keyword];
319+
$display = is_scalar($value) ? (string) $value : json_encode($value);
320+
$hints[] = sprintf($template, $display);
321+
}
322+
323+
if (!empty($hints)) {
324+
$existing = $schema['description'] ?? '';
325+
$suffix = implode(' ', $hints);
326+
$schema['description'] = $existing !== ''
327+
? $existing . ' ' . $suffix
328+
: $suffix;
329+
}
330+
331+
return $schema;
332+
}
92333
}

0 commit comments

Comments
 (0)