From 423e5c97e4776ebbf0ccdee0df2e91da346f0521 Mon Sep 17 00:00:00 2001 From: Nikola Katsarov Date: Tue, 4 Aug 2026 08:58:41 +0300 Subject: [PATCH] fix(tools): accept integer OR string for browser_task max_steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sentry #1084: Groq rejected the tool call with `invalid_request_error … parameters for tool browser_task did not match schema: /max_steps: expected number, but got string` — the model emitted "10" instead of 10. Providers validate the model's call against our ADVERTISED schema server-side, so the call never reaches us and we cannot coerce it. Widen the advertised type to ["integer","string"] on all three browser_task declarations (disabled/inert, sidecar, browser-use Cloud) and normalise to int at the seam. RawSchema keeps it a plain JSON Schema type union rather than anyOf, which strict function-calling modes tend to reject. #937 (BuildArtifactJob: Failed to build artifact) was the downstream failure of the same run. --- app/Domain/Tool/Services/ToolTranslator.php | 28 ++++++++--- .../Tool/ToolTranslatorBrowserPolicyTest.php | 47 +++++++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/app/Domain/Tool/Services/ToolTranslator.php b/app/Domain/Tool/Services/ToolTranslator.php index 651415850..3e6cd9657 100644 --- a/app/Domain/Tool/Services/ToolTranslator.php +++ b/app/Domain/Tool/Services/ToolTranslator.php @@ -22,6 +22,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; use Prism\Prism\Facades\Tool as PrismTool; +use Prism\Prism\Schema\RawSchema; use Prism\Prism\Tool as PrismToolObject; class ToolTranslator @@ -624,7 +625,7 @@ private function buildBrowserTools(Tool $tool): array ->for('Autonomously browse the web to complete a task (navigate, click, fill forms, extract data)') ->withStringParameter('task', 'Natural language description of the browsing task to perform') ->withStringParameter('start_url', 'Optional starting URL', required: false) - ->withNumberParameter('max_steps', 'Maximum number of browser steps (default: 10)', required: false) + ->withParameter(new RawSchema('max_steps', ['type' => ['integer', 'string'], 'description' => 'Maximum number of browser steps (default: 10)']), required: false) ->using(fn () => 'Error: Browser automation requires a paid plan. Please upgrade to Starter or above.'), ]; } @@ -636,14 +637,14 @@ private function buildBrowserTools(Tool $tool): array ->for('Autonomously browse the web to complete a task (navigate, click, fill forms, extract data). Returns the extracted result as text. Set headless=false for sites with anti-bot protection (Reddit, Cloudflare-protected sites) — runs in a virtual display.') ->withStringParameter('task', 'Natural language description of the browsing task to perform') ->withStringParameter('start_url', 'Optional starting URL to begin from', required: false) - ->withNumberParameter('max_steps', 'Maximum number of browser steps (default: 10, plan-capped)', required: false) + ->withParameter(new RawSchema('max_steps', ['type' => ['integer', 'string'], 'description' => 'Maximum number of browser steps (default: 10, plan-capped)']), required: false) ->withStringParameter('headless', 'Run browser in headless mode. Pass "true" (default) or "false". Use "false" for sites with anti-bot detection (Reddit, Cloudflare challenges) — uses a real visible Chrome in a virtual display.', required: false) - ->using(function (string $task, ?string $start_url = null, ?int $max_steps = null, ?string $headless = null) use ($mode, $toolModel): string { + ->using(function (string $task, ?string $start_url = null, int|string|null $max_steps = null, ?string $headless = null) use ($mode, $toolModel): string { if ($denial = $this->browserPlanDenial($toolModel)) { return $denial; } - $options = $this->browserTaskOptions($toolModel, $mode, $start_url, $max_steps, $headless); + $options = $this->browserTaskOptions($toolModel, $mode, $start_url, self::normaliseMaxSteps($max_steps), $headless); return $this->executeBrowserTask($toolModel, $mode, $task, $options); }), @@ -672,6 +673,19 @@ private function browserPlanDenial(Tool $toolModel): ?string * * @return array */ + /** + * The advertised schema for `max_steps` accepts integer OR string because + * providers validate the model's tool call against it server-side and reject + * the whole call on a type mismatch — Groq returned + * `invalid_request_error … /max_steps: expected number, but got string` + * (Sentry #1084) when the model emitted "10" instead of 10. We cannot coerce + * a call we never receive, so the schema tolerates both and we normalise here. + */ + private static function normaliseMaxSteps(int|string|null $maxSteps): ?int + { + return $maxSteps === null || $maxSteps === '' ? null : (int) $maxSteps; + } + private function browserTaskOptions(Tool $toolModel, string $mode, ?string $startUrl, ?int $maxSteps, ?string $headless): array { // Cap max_steps to the plan limit. @@ -843,8 +857,8 @@ private function buildBrowserUseCloudTools(Tool $tool): array ->for('Autonomously browse the web to complete a task via browser-use Cloud (cloud.browser-use.com). Natural language task description, returns the extracted result as text. Good for: form filling, data extraction, multi-step navigation, sites that need a real browser.') ->withStringParameter('task', 'Natural language description of the browsing task to perform') ->withStringParameter('start_url', 'Optional starting URL to begin from', required: false) - ->withNumberParameter('max_steps', 'Maximum number of browser steps (default: 10)', required: false) - ->using(function (string $task, ?string $start_url = null, ?int $max_steps = null) use ($toolModel): string { + ->withParameter(new RawSchema('max_steps', ['type' => ['integer', 'string'], 'description' => 'Maximum number of browser steps (default: 10)']), required: false) + ->using(function (string $task, ?string $start_url = null, int|string|null $max_steps = null) use ($toolModel): string { // Execution-time plan gate — cloud registers 'browser.plan_gate' as a callable. if ($toolModel->team_id && app()->bound('browser.plan_gate')) { $gate = app('browser.plan_gate'); @@ -853,7 +867,7 @@ private function buildBrowserUseCloudTools(Tool $tool): array } } - $effectiveMaxSteps = $max_steps ?? 10; + $effectiveMaxSteps = self::normaliseMaxSteps($max_steps) ?? 10; if ($toolModel->team_id && app()->bound('browser.max_steps_gate')) { $planMaxSteps = app('browser.max_steps_gate')($toolModel->team_id); if ($planMaxSteps > 0) { diff --git a/tests/Unit/Domain/Tool/ToolTranslatorBrowserPolicyTest.php b/tests/Unit/Domain/Tool/ToolTranslatorBrowserPolicyTest.php index 0fe514efe..affd4bcb5 100644 --- a/tests/Unit/Domain/Tool/ToolTranslatorBrowserPolicyTest.php +++ b/tests/Unit/Domain/Tool/ToolTranslatorBrowserPolicyTest.php @@ -163,4 +163,51 @@ public function test_build_browser_use_cloud_tools_forwards_allowed_domains_in_v && $body['allowedDomains'] === ['corp.example.com']; }); } + + public function test_max_steps_schema_accepts_integer_or_string(): void + { + // Providers validate the model's tool call against the ADVERTISED schema + // server-side and reject the whole call on a type mismatch — Groq returned + // `/max_steps: expected number, but got string` when the model emitted "10" + // (Sentry #1084). We never receive such a call, so the schema must tolerate + // both types. Guards all three browser_task declarations. + config(['agent.browser_sandbox_mode' => 'sidecar']); + + foreach ([['kind' => 'browser'], ['kind' => 'browser_use_cloud']] as $transport) { + $tool = Tool::factory()->create([ + 'type' => ToolType::BuiltIn, + 'transport_config' => $transport, + 'credentials' => ['api_key' => 'bu-test-key'], + ]); + + $parameters = app(ToolTranslator::class)->toPrismTools($tool)[0]->parameters(); + + $this->assertArrayHasKey('max_steps', $parameters); + $this->assertSame( + ['integer', 'string'], + $parameters['max_steps']->toArray()['type'], + "browser_task max_steps must accept both types for {$transport['kind']}", + ); + } + } + + public function test_max_steps_given_as_a_string_is_coerced_before_dispatch(): void + { + config(['agent.browser_sandbox_mode' => 'sidecar']); + Http::fake([ + 'http://browser_sidecar:8090/run' => Http::response([ + 'status' => 'success', 'output' => 'done', 'steps_taken' => 1, + 'duration_ms' => 100, 'screenshots' => [], 'urls_visited' => [], + ], 200), + ]); + + $tool = Tool::factory()->create([ + 'type' => ToolType::BuiltIn, + 'transport_config' => ['kind' => 'browser'], + ]); + + app(ToolTranslator::class)->toPrismTools($tool)[0]->handle(task: 'search for dogs', max_steps: '7'); + + Http::assertSent(fn ($request) => ($request->data()['max_steps'] ?? null) === 7); + } }