Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 7 additions & 14 deletions app/Ai/Agents/MediaTrackingAgent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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')]
Expand Down Expand Up @@ -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: `- <title> (<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.
Expand All @@ -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.
Expand Down Expand Up @@ -132,7 +125,7 @@ public function instructions(): Stringable|string
public function tools(): iterable
{
$tools = [
new WebSearch,
new MediaWebSearchAgentTool,
new SearchMedia,
$this->confirmationTool ?? new RequestConfirmation,
];
Expand Down
88 changes: 88 additions & 0 deletions app/Ai/Tools/MediaWebSearchAgentTool.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
16 changes: 14 additions & 2 deletions tests/Feature/Ai/MediaTrackingAgentTest.php
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions tests/Feature/Ai/Tools/MediaWebSearchAgentToolTest.php
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading