Skip to content

Commit ea5556c

Browse files
committed
Merge prism-php#1009 — refactor(tool): prevent circular references and improve handler resolution for invokable subclasses
2 parents 88a8b50 + a207a10 commit ea5556c

5 files changed

Lines changed: 85 additions & 9 deletions

File tree

docs/core-concepts/tools-function-calling.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,7 @@ class SearchTool extends Tool
253253
$this
254254
->as('search')
255255
->for('useful when you need to search for current events')
256-
->withStringParameter('query', 'Detailed search query. Best to search one topic at a time.')
257-
->using($this);
256+
->withStringParameter('query', 'Detailed search query. Best to search one topic at a time.');
258257
}
259258

260259
public function __invoke(string $query): string

src/Concerns/CallsTools.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,12 @@ protected function executeToolsWithConcurrency(array $tools, array $groupedToolC
126126
* @param Tool[] $tools
127127
* @return array{toolResult: ToolResult, events: array<int, ToolResultEvent|ArtifactEvent>}
128128
*/
129-
protected function executeToolCall(array $tools, ToolCall $toolCall, string $messageId): array
129+
protected static function executeToolCall(array $tools, ToolCall $toolCall, string $messageId): array
130130
{
131131
$events = [];
132132

133133
try {
134-
$tool = $this->resolveTool($toolCall->name, $tools);
134+
$tool = self::resolveTool($toolCall->name, $tools);
135135
$output = call_user_func_array(
136136
$tool->handle(...),
137137
$toolCall->arguments()
@@ -224,8 +224,10 @@ protected function executeToolCall(array $tools, ToolCall $toolCall, string $mes
224224

225225
/**
226226
* @param Tool[] $tools
227+
*
228+
* @throws PrismException
227229
*/
228-
protected function resolveTool(string $name, array $tools): Tool
230+
protected static function resolveTool(string $name, array $tools): Tool
229231
{
230232
try {
231233
return collect($tools)

src/Tool.php

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class Tool
3939
/** @var array <int, string> */
4040
protected array $requiredParameters = [];
4141

42-
/** @var Closure():mixed|callable():mixed */
42+
/** @var Closure():mixed|callable():mixed|null */
4343
protected $fn;
4444

4545
/** @var null|false|Closure(Throwable,array<int|string,mixed>):string */
@@ -68,6 +68,10 @@ public function for(string $description): self
6868

6969
public function using(Closure|callable $fn): self
7070
{
71+
if ($fn === $this) {
72+
return $this;
73+
}
74+
7175
$this->fn = $fn;
7276

7377
return $this;
@@ -262,7 +266,9 @@ public function failedHandler(): null|false|Closure
262266
public function handle(...$args): string|ToolOutput|ToolError
263267
{
264268
try {
265-
$value = call_user_func($this->fn, ...$args);
269+
$callable = $this->resolveHandler();
270+
271+
$value = call_user_func($callable, ...$args);
266272

267273
if (is_string($value)) {
268274
return $value;
@@ -281,6 +287,35 @@ public function handle(...$args): string|ToolOutput|ToolError
281287
}
282288
}
283289

290+
/**
291+
* Resolve the callable handler for this tool.
292+
*
293+
* Priority: explicit $fn > invokable subclass (__invoke) > error.
294+
* Also unwraps SerializableClosure wrappers that break named arguments.
295+
*/
296+
protected function resolveHandler(): callable
297+
{
298+
$fn = $this->fn;
299+
300+
if ($fn === null && method_exists($this, '__invoke')) {
301+
$fn = $this;
302+
}
303+
304+
if ($fn === null) {
305+
throw new PrismException("Tool handler not defined for tool: {$this->name}");
306+
}
307+
308+
// After ProcessDriver deserialization, $fn may become a
309+
// SerializableClosure\Serializers\Native whose __invoke doesn't
310+
// forward PHP 8 named arguments. Unwrap via getClosure() to
311+
// recover the real Closure so named-arg spreading works.
312+
if (is_object($fn) && method_exists($fn, 'getClosure')) {
313+
return $fn->getClosure();
314+
}
315+
316+
return $fn;
317+
}
318+
284319
protected function shouldHandleErrors(): bool
285320
{
286321
return $this->failedHandler !== false;

src/Tools/LaravelMcpTool.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ class LaravelMcpTool extends Tool
1717
public function __construct(private readonly \Laravel\Mcp\Server\Tool $tool)
1818
{
1919
$this->as($tool->name())
20-
->for($tool->description())
21-
->using($this);
20+
->for($tool->description());
2221

2322
$data = $tool->toArray();
2423
$properties = $data['inputSchema']['properties'] ?? [];

tests/ToolTest.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,47 @@ public function __invoke(string $query): string
8585
->toBe('The event is at 3pm eastern');
8686
});
8787

88+
it('handles invokable subclass with using($this) without circular reference', function (): void {
89+
$tool = new class extends Tool
90+
{
91+
public function __construct()
92+
{
93+
parent::__construct();
94+
$this->as('test_tool')
95+
->for('A test tool')
96+
->withParameter(new StringSchema('query', 'the query'))
97+
->using($this);
98+
}
99+
100+
public function __invoke(string $query): string
101+
{
102+
return "Result: $query";
103+
}
104+
};
105+
106+
expect($tool->handle(query: 'hello'))->toBe('Result: hello');
107+
});
108+
109+
it('invokable subclass works without calling using() at all', function (): void {
110+
$tool = new class extends Tool
111+
{
112+
public function __construct()
113+
{
114+
parent::__construct();
115+
$this->as('auto_tool')
116+
->for('Auto-detected invokable')
117+
->withParameter(new StringSchema('input', 'the input'));
118+
}
119+
120+
public function __invoke(string $input): string
121+
{
122+
return "Auto: $input";
123+
}
124+
};
125+
126+
expect($tool->handle(input: 'test'))->toBe('Auto: test');
127+
});
128+
88129
it('can have fluent parameters', function (): void {
89130
$tool = (new Tool)
90131
->as('test tool')

0 commit comments

Comments
 (0)