diff --git a/app/Ai/Agents/MediaTrackingAgent.php b/app/Ai/Agents/MediaTrackingAgent.php index cd42141..39c11ff 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,20 +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 use web search to confirm the publication year and primary creator before responding. + 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: `- (<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 + 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?" - 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?" - - 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. @@ -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..64229da 100644 --- a/tests/Feature/Ai/MediaTrackingAgentTest.php +++ b/tests/Feature/Ai/MediaTrackingAgentTest.php @@ -1,6 +1,7 @@ <?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; @@ -32,16 +33,27 @@ }); 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)); }); + // 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; 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); + }); +});