From 1822e7d11d8713dfef035dd733cb65e4aeff0f92 Mon Sep 17 00:00:00 2001 From: David Harting Date: Mon, 11 May 2026 11:56:13 -0400 Subject: [PATCH 1/3] Move web search into a sub-agent tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrismPHP's Anthropic provider returns 400 errors when an agent mixes custom tools with provider tools (like WebSearch) across multi-turn conversations. Extract WebSearch into MediaWebSearchAgentTool — a custom tool whose handle() spawns a sub-agent with WebSearch as its only tool — so the orchestrator only owns custom tools. Mirrors the existing MediaWritingAgentTool pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Ai/Agents/MediaTrackingAgent.php | 19 ++-- app/Ai/Tools/MediaWebSearchAgentTool.php | 88 +++++++++++++++++++ tests/Feature/Ai/MediaTrackingAgentTest.php | 6 +- .../Ai/Tools/MediaWebSearchAgentToolTest.php | 27 ++++++ 4 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 app/Ai/Tools/MediaWebSearchAgentTool.php create mode 100644 tests/Feature/Ai/Tools/MediaWebSearchAgentToolTest.php diff --git a/app/Ai/Agents/MediaTrackingAgent.php b/app/Ai/Agents/MediaTrackingAgent.php index cd42141..337d863 100644 --- a/app/Ai/Agents/MediaTrackingAgent.php +++ b/app/Ai/Agents/MediaTrackingAgent.php @@ -2,6 +2,7 @@ namespace App\Ai\Agents; +use App\Ai\Tools\MediaWebSearchAgentTool; use App\Ai\Tools\MediaWritingAgentTool; use App\Ai\Tools\RequestConfirmation; use App\Ai\Tools\SearchMedia; @@ -13,7 +14,6 @@ use Laravel\Ai\Contracts\HasTools; use Laravel\Ai\Contracts\Tool; use Laravel\Ai\Promptable; -use Laravel\Ai\Providers\Tools\WebSearch; use Stringable; #[Provider('anthropic')] @@ -58,18 +58,9 @@ public function instructions(): Stringable|string When David tells you about a piece of media he wants to track, identify the exact item with precision. - Always use web search to confirm the publication year and primary creator before responding. + Always call the MediaWebSearchAgentTool to identify the media before responding. Pass a condensed description of what David said (title, creator, year, type, plot hints — whatever he provided), NOT a search query string. The tool returns either "No matches found." or markdown bullets in the format: `- (<year>) — <creator> — <media_type>` where media_type is one of: album, book, movie, tv show, video game. - Primary creator by media type: - - Album → artist - - Book → author - - Movie → director - - TV show → creator or showrunner - - Video game → developer studio - - One creator only. Pick the single most relevant primary creator. For example, for a movie with multiple directors, pick the lead. - - Flag ambiguity. If search results reveal more than one plausible match — such as a remake, an adaptation, or multiple works with the same title — tell David and ask which one he means. For example: "I found two possibilities: 'Dune' (1965 novel by Frank Herbert) or 'Dune' (2021 film by Denis Villeneuve). Which did you mean?" + Flag ambiguity. If the tool returns more than one bullet — such as a remake, an adaptation, or multiple works with the same title — tell David and ask which one he means. For example: "I found two possibilities: 'Dune' (1965 novel by Frank Herbert) or 'Dune' (2021 film by Denis Villeneuve). Which did you mean?" Once you have identified the item with confidence, use the SearchMedia tool to look it up in David's library by title (and media type if known). @@ -80,6 +71,8 @@ public function instructions(): Stringable|string - If found and current_status is "finished": David has already finished it. - If found and current_status is "abandoned": David previously abandoned it. + If MediaWebSearchAgentTool returns "No matches found.", let David know you couldn't identify the item from the description and ask for more detail. + Supported event types are: started, finished, abandoned, and comment. Comment events do not change the media status — they attach a free-text note to a media item (e.g. a thought, recommendation, or reflection). A comment can also be attached directly to a started, finished, or abandoned event — you don't need a separate comment event for that. Use a standalone comment event only when David wants to record a note without logging any status change. @@ -132,7 +125,7 @@ public function instructions(): Stringable|string public function tools(): iterable { $tools = [ - new WebSearch, + new MediaWebSearchAgentTool, new SearchMedia, $this->confirmationTool ?? new RequestConfirmation, ]; diff --git a/app/Ai/Tools/MediaWebSearchAgentTool.php b/app/Ai/Tools/MediaWebSearchAgentTool.php new file mode 100644 index 0000000..4a1cc66 --- /dev/null +++ b/app/Ai/Tools/MediaWebSearchAgentTool.php @@ -0,0 +1,88 @@ +<?php + +namespace App\Ai\Tools; + +use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Log; +use Laravel\Ai\Contracts\Tool; +use Laravel\Ai\Providers\Tools\WebSearch; +use Laravel\Ai\Tools\Request; +use Stringable; + +use function Laravel\Ai\agent; + +class MediaWebSearchAgentTool implements Tool +{ + public function description(): Stringable|string + { + return 'Identify a piece of media via web search. Pass a condensed description of what the user told you about the media — NOT a search query string. Returns 0, 1, or multiple candidate matches as markdown bullets.'; + } + + public function handle(Request $request): Stringable|string + { + $query = $request->string('query', ''); + + if ($query->isEmpty()) { + return json_encode( + ['error' => 'query must not be empty. Pass a condensed description of the target media.'], + JSON_THROW_ON_ERROR, + ); + } + + Log::info('MediaWebSearchAgentTool called', ['query' => $query]); + + $response = agent( + instructions: $this->instructions(), + tools: [new WebSearch], + )->prompt((string) $query, provider: 'anthropic', model: 'claude-sonnet-4-6'); + + return $response->text; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'query' => $schema->string()->required() + ->description('Condensed description of the target media — NOT a search query string. Include whatever the user said: title, partial title, creator name, year, media type, plot hints, etc. Examples: "novel called Ghostwritten by David Mitchell", "the 2021 Dune movie", "that sci-fi book about sandworms".'), + ]; + } + + private function instructions(): string + { + return <<<'PROMPT' + You identify a piece of media based on a condensed description provided by an orchestrator agent. + + Use the WebSearch tool to find candidate matches. You may search multiple times if needed to disambiguate. + + **Primary creator by media type** + - Album → artist + - Book → author + - Movie → director + - TV show → creator or showrunner + - Video game → developer studio + + Pick one creator only — the single most relevant primary creator. For example, for a movie with multiple directors, pick the lead. + + **Media type values** + media_type must be one of: album, book, movie, tv show, video game + + **Return format** + + If no plausible match exists, return EXACTLY this text and nothing else: + No matches found. + + If one or more plausible matches exist, return markdown bullets — one per line — in this exact format: + - <title> (<year>) — <creator> — <media_type> + + Example with one match: + - Ghostwritten (1999) — David Mitchell — book + + Example with multiple matches (when the query is ambiguous, e.g. a remake, an adaptation, or multiple works sharing a title): + - Dune (1965) — Frank Herbert — book + - Dune (1984) — David Lynch — movie + - Dune (2021) — Denis Villeneuve — movie + + Return ONLY the bullets (or the "No matches found." sentence). No preamble, no prose, no trailing summary, no explanation. The orchestrator handles disambiguation with the user. + PROMPT; + } +} diff --git a/tests/Feature/Ai/MediaTrackingAgentTest.php b/tests/Feature/Ai/MediaTrackingAgentTest.php index ff1429a..0139414 100644 --- a/tests/Feature/Ai/MediaTrackingAgentTest.php +++ b/tests/Feature/Ai/MediaTrackingAgentTest.php @@ -1,13 +1,13 @@ <?php use App\Ai\Agents\MediaTrackingAgent; +use App\Ai\Tools\MediaWebSearchAgentTool; use App\Ai\Tools\MediaWritingAgentTool; use App\Ai\Tools\RequestConfirmation; use App\Ai\Tools\SearchMedia; use Illuminate\Foundation\Testing\TestCase; use Laravel\Ai\Attributes\Model; use Laravel\Ai\Attributes\Provider; -use Laravel\Ai\Providers\Tools\WebSearch; test("uses Anthropic's Sonnet 4.6", function () { /** @var TestCase $this */ @@ -32,12 +32,12 @@ }); describe('tools()', function () { - test('includes WebSearch, SearchMedia, and RequestConfirmation by default', function () { + test('includes MediaWebSearchAgentTool, SearchMedia, and RequestConfirmation by default', function () { /** @var TestCase $this */ $agent = MediaTrackingAgent::make(); $tools = collect($agent->tools()); - $this->assertTrue($tools->contains(fn ($tool) => $tool instanceof WebSearch)); + $this->assertTrue($tools->contains(fn ($tool) => $tool instanceof MediaWebSearchAgentTool)); $this->assertTrue($tools->contains(fn ($tool) => $tool instanceof SearchMedia)); $this->assertTrue($tools->contains(fn ($tool) => $tool instanceof RequestConfirmation)); }); diff --git a/tests/Feature/Ai/Tools/MediaWebSearchAgentToolTest.php b/tests/Feature/Ai/Tools/MediaWebSearchAgentToolTest.php new file mode 100644 index 0000000..3e92966 --- /dev/null +++ b/tests/Feature/Ai/Tools/MediaWebSearchAgentToolTest.php @@ -0,0 +1,27 @@ +<?php + +use App\Ai\Tools\MediaWebSearchAgentTool; +use Illuminate\Foundation\Testing\TestCase; +use Laravel\Ai\Tools\Request; + +describe('handle()', function () { + test('returns error when query is empty string', function () { + /** @var TestCase $this */ + $result = json_decode( + (new MediaWebSearchAgentTool)->handle(new Request(['query' => ''])), + true, + ); + + $this->assertArrayHasKey('error', $result); + }); + + test('returns error when query is not provided', function () { + /** @var TestCase $this */ + $result = json_decode( + (new MediaWebSearchAgentTool)->handle(new Request([])), + true, + ); + + $this->assertArrayHasKey('error', $result); + }); +}); From ad8000868b7a25be09a7c54ea792e9a58cae3dd3 Mon Sep 17 00:00:00 2001 From: David Harting <hartingdavid@icloud.com> Date: Mon, 11 May 2026 11:57:30 -0400 Subject: [PATCH 2/3] Frame web search as purpose-driven, not mandatory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the orchestrator was told to "always" call MediaWebSearchAgentTool before responding, which caused needless web searches when David referred to media already in his library (e.g. logging a finished event on something currently being read). Reframe the guidance around the tool's purpose — identifying unknown media — so the orchestrator can skip it when SearchMedia alone is sufficient. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- app/Ai/Agents/MediaTrackingAgent.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Ai/Agents/MediaTrackingAgent.php b/app/Ai/Agents/MediaTrackingAgent.php index 337d863..39c11ff 100644 --- a/app/Ai/Agents/MediaTrackingAgent.php +++ b/app/Ai/Agents/MediaTrackingAgent.php @@ -58,11 +58,11 @@ public function instructions(): Stringable|string When David tells you about a piece of media he wants to track, identify the exact item with precision. - Always call the MediaWebSearchAgentTool to identify the media before responding. Pass a condensed description of what David said (title, creator, year, type, plot hints — whatever he provided), NOT a search query string. The tool returns either "No matches found." or markdown bullets in the format: `- <title> (<year>) — <creator> — <media_type>` where media_type is one of: album, book, movie, tv show, video game. + Use MediaWebSearchAgentTool when you need to identify a piece of media you don't already know — typically when David is adding something new, or when a library lookup turns up nothing or is ambiguous. You don't need it when David is referring to something already in his library and SearchMedia is enough to find it. Pass a condensed description of what David said (title, creator, year, type, plot hints — whatever he provided), NOT a search query string. The tool returns either "No matches found." or markdown bullets in the format: `- <title> (<year>) — <creator> — <media_type>` where media_type is one of: album, book, movie, tv show, video game. - Flag ambiguity. If the tool returns more than one bullet — such as a remake, an adaptation, or multiple works with the same title — tell David and ask which one he means. For example: "I found two possibilities: 'Dune' (1965 novel by Frank Herbert) or 'Dune' (2021 film by Denis Villeneuve). Which did you mean?" + Flag ambiguity. If MediaWebSearchAgentTool returns more than one bullet — such as a remake, an adaptation, or multiple works with the same title — tell David and ask which one he means. For example: "I found two possibilities: 'Dune' (1965 novel by Frank Herbert) or 'Dune' (2021 film by Denis Villeneuve). Which did you mean?" - Once you have identified the item with confidence, use the SearchMedia tool to look it up in David's library by title (and media type if known). + Use the SearchMedia tool to look up an item in David's library by title (and media type if known). Interpret the SearchMedia result as follows: - If no results are found: the item is not in the library. Confirm the item's identity (title, year, creator, type) and let David know it is not yet in his library. From 91fa5002e6640af195f5e1f74e43b4ca3ee6cb6d Mon Sep 17 00:00:00 2001 From: David Harting <hartingdavid@icloud.com> Date: Tue, 12 May 2026 21:18:43 -0400 Subject: [PATCH 3/3] Test that WebSearch provider tool is not in MediaTrackingAgent tools Documents the PrismPHP/Anthropic 400-error bug that motivates wrapping WebSearch inside MediaWebSearchAgentTool instead of exposing it directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- tests/Feature/Ai/MediaTrackingAgentTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/Feature/Ai/MediaTrackingAgentTest.php b/tests/Feature/Ai/MediaTrackingAgentTest.php index 0139414..64229da 100644 --- a/tests/Feature/Ai/MediaTrackingAgentTest.php +++ b/tests/Feature/Ai/MediaTrackingAgentTest.php @@ -8,6 +8,7 @@ use Illuminate\Foundation\Testing\TestCase; use Laravel\Ai\Attributes\Model; use Laravel\Ai\Attributes\Provider; +use Laravel\Ai\Providers\Tools\WebSearch; test("uses Anthropic's Sonnet 4.6", function () { /** @var TestCase $this */ @@ -42,6 +43,17 @@ $this->assertTrue($tools->contains(fn ($tool) => $tool instanceof RequestConfirmation)); }); + // At time of writing, PrismPHP's Anthropic provider returns 400s when an agent mixes custom tools with provider + // tools (like WebSearch) across multi-turn conversations. WebSearch is wrapped in + // MediaWebSearchAgentTool (a sub-agent) so the orchestrator only owns custom tools. + test('does not expose the WebSearch provider tool directly', function () { + /** @var TestCase $this */ + $agent = MediaTrackingAgent::make(); + $tools = collect($agent->tools()); + + $this->assertFalse($tools->contains(fn ($tool) => $tool instanceof WebSearch)); + }); + test('includes injected RequestConfirmation instance', function () { /** @var TestCase $this */ $confirmationTool = new RequestConfirmation;