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: `-
() — — ` 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 @@
+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:
+ - () — —
+
+ 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 @@
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 @@
+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);
+ });
+});