Skip to content

Concurrent tool execution fails with Laravel's ProcessDriver due to circular reference and $this capture in closures #893

Description

@joao-salomao-ug

When using ->concurrent() on class-based invokable tools, concurrent execution via Laravel's Concurrency::run() fails because:

  1. Circular reference in Tool objects: The docs instruct class-based tools to call ->using($this), which stores the Tool instance inside its own $fn property (Tool -> $fn -> Tool). This creates a circular reference that SerializableClosure cannot serialize.

  2. CallsTools closures capture $this: The closures passed to Concurrency::run() in executeToolsWithConcurrency are not static, so they implicitly capture $this (the Stream/Text handler), which transitively includes the entire application container. This causes serialization failures with Laravel's ProcessDriver (e.g. readonly property errors from packages like spatie/laravel-data, or Closure properties from dedoc/scramble).

  3. resolveTool called inside the serialized closure: executeToolCall resolves the tool internally, meaning the $tools array (containing all Tool instances with their circular references) gets serialized into the closure.

Here are the specific error examples we encountered:

Dedoc\Scramble: Closure property serialization failure

When Laravel's ProcessDriver tries to serialize the closure captured by Concurrency::run(), it encounters Dedoc\Scramble\GeneratorConfig which has a Closure property. SerializableClosure tries to wrap it and assign it back, but the property has a union type that doesn't accept the wrapper:

TypeError: Cannot assign Laravel\SerializableClosure\Serializers\Native
to property Dedoc\Scramble\GeneratorConfig::$uiRoute
of type Closure|string|null

#0 vendor/laravel/serializable-closure/src/Serializers/Native.php(229):
   Laravel\SerializableClosure\Serializers\Native::wrapClosures(
       Object(Dedoc\Scramble\GeneratorConfig),
       Object(Laravel\SerializableClosure\Support\ClosureScope)
   )

This happens because the non-static closure in CallsTools captures $this (the Stream/Text handler), which holds a reference to the Laravel container, which transitively includes every registered singleton — including Dedoc\Scramble\GeneratorConfig.

Spatie\LaravelData: Readonly property serialization failure

Same root cause. The serializer walks the entire object graph captured by $this and tries to modify a readonly property on Spatie\LaravelData\Support\DataType:

Error: Cannot modify readonly property Spatie\LaravelData\Support\DataType::$type

#0 vendor/laravel/serializable-closure/src/Serializers/Native.php(286):
   Laravel\SerializableClosure\Serializers\Native::wrapClosures(...)

SerializableClosure internally clones objects and tries to recursively wrap any closures it finds. When it hits a readonly property, the assignment fails because PHP prohibits modification of readonly properties after initialization.

Reproduction

class TestTool extends Tool
{
    public function __construct()
    {
        parent::__construct();
        $this
            ->as('test')
            ->for('Test tool')
            ->concurrent()
            ->using($this);
    }

    public function __invoke(): string
    {
        return 'Test';
    }
}

$result = Concurrency::run([
    'a' => static function () {
        sleep(1);

        return 'First';
    },
    'b' => static function () {
        sleep(1);

        return 'Second';
    },
    'c' => static function () {
        $tool = new TestTool();

        return $tool();
    },
]);

dd($result);

// When Prism calls this tool concurrently via Concurrency::run(),
// the ProcessDriver tries to serialize the Tool object, which fails due to
// the circular reference and the implicit $this capture in CallsTools closures.

Proposed Fix

1. Tool::handle(): Prefer __invoke() over $fn for class-based tools

This eliminates the need for ->using($this) entirely on invokable tool classes, breaking the circular reference. Inline tools defined with ->using(fn () => ...) continue to work via $fn.

// src/Tool.php
public function handle(...$args): string|ToolOutput
{
    try {
        $callable = method_exists($this, '__invoke') ? $this : $this->fn;
        $value = call_user_func($callable, ...$args);
        // ...
    }
}

2. CallsTools::executeToolsWithConcurrency(): Use static closures and resolve tools outside

This prevents the Stream/Text handler ($this) and its entire object graph from being serialized. The tool is resolved before the closure is created and passed as a plain argument.

// src/Concerns/CallsTools.php
protected function executeToolsWithConcurrency(array $tools, array $groupedToolCalls, string $messageId): array
{
    $results = [];
    $concurrentClosures = [];

    foreach ($groupedToolCalls['concurrent'] as $index => $toolCall) {
        $tool = $this->resolveTool($toolCall->name, $tools);
        $concurrentClosures[$index] = static fn () => self::executeToolCall($tool, $toolCall, $messageId);
    }

    if ($concurrentClosures !== []) {
        foreach (Concurrency::run($concurrentClosures) as $index => $result) {
            $results[$index] = $result;
        }
    }

    foreach ($groupedToolCalls['sequential'] as $index => $toolCall) {
        $tool = $this->resolveTool($toolCall->name, $tools);
        $results[$index] = self::executeToolCall($tool, $toolCall, $messageId);
    }

    return $results;
}

protected static function executeToolCall(Tool $tool, ToolCall $toolCall, string $messageId): array
{
    // ... existing logic, but receives the resolved Tool directly
}

Additional Notes

  • Users should also avoid injecting large objects (e.g. Eloquent models with massive JSON columns) into tool constructors, as the serialized payload can exceed the OS ARG_MAX limit. Prefer passing IDs and fetching within __invoke().
  • The ->using($this) call is redundant for class-based invokable tools since handle() can call __invoke() directly. The docs should be updated to remove ->using($this) from class-based tool examples, or at minimum document it as unnecessary.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions