diff --git a/components/home/HeroDemo.tsx b/components/home/HeroDemo.tsx index 767be54..5743636 100644 --- a/components/home/HeroDemo.tsx +++ b/components/home/HeroDemo.tsx @@ -24,6 +24,7 @@ import { isToolPart, type ToolPartLike, } from "@/components/tool-call/tool-part"; +import { AgentTree } from "@/components/tool-call/agent-tree"; interface SpotifyArtist { id: string; @@ -38,6 +39,65 @@ const FEATURED_ARTIST_NAMES = ["Drake", "Tyla", "SZA", "Doja Cat"]; const CYCLE_INTERVAL_MS = 3500; const transport = new DefaultChatTransport({ api: "/api/demo" }); +// Discriminated union of "things to render" once we've grouped consecutive +// tool parts. A run of 2+ tool parts collapses into a single "agent-tree" +// group so it renders as a grid; a lone tool part stays inline. +type RenderGroup = + | { kind: "text"; text: string; idx: number } + | { kind: "reasoning"; text: string; idx: number } + | { kind: "single-tool"; part: ToolPartLike; idx: number } + | { kind: "agent-tree"; tools: ToolPartLike[]; idx: number }; + +/** + * Walk an assistant message's parts in order and bucket them into + * RenderGroups so the renderer can decide where to slot an AgentTree + * (multi-tool fan-out) vs. a single-tool inline card vs. text vs. + * reasoning. Empty text parts (mid-stream placeholders) are dropped. + */ +function groupParts(parts: readonly unknown[]): RenderGroup[] { + const groups: RenderGroup[] = []; + let toolBuffer: { part: ToolPartLike; idx: number }[] = []; + + function flushTools() { + if (toolBuffer.length === 0) return; + if (toolBuffer.length === 1) { + groups.push({ + kind: "single-tool", + part: toolBuffer[0].part, + idx: toolBuffer[0].idx, + }); + } else { + groups.push({ + kind: "agent-tree", + tools: toolBuffer.map((t) => t.part), + idx: toolBuffer[0].idx, + }); + } + toolBuffer = []; + } + + parts.forEach((rawPart, idx) => { + const part = rawPart as { type?: string; text?: string }; + if (typeof part.type !== "string") return; + + if (isToolPart(part as { type: string })) { + toolBuffer.push({ part: part as unknown as ToolPartLike, idx }); + return; + } + + flushTools(); + + if (part.type === "text" && part.text) { + groups.push({ kind: "text", text: part.text, idx }); + } else if (part.type === "reasoning" && part.text) { + groups.push({ kind: "reasoning", text: part.text, idx }); + } + }); + + flushTools(); + return groups; +} + // Follow-up action pills under the latest assistant response. Every option // has to map to behavior we can actually deliver — either by re-using the // data already in the prior `similar_artists` tool result (no new tool call @@ -439,42 +499,38 @@ export function HeroDemo() { )} - {/* Card body — render parts in their actual order so - tool calls interleave correctly with text + reasoning */} + {/* Card body — render parts in their actual order, but + group consecutive tool parts so a multi-agent fan-out + renders as a single AgentTree (grid) instead of + stacked individual cards. */}
- {msg.parts.map((part, idx) => { - if (part.type === "text") { - if (!part.text) return null; + {groupParts(msg.parts).map((g, gi) => { + if (g.kind === "text") { return ( - {part.text} + {g.text} ); } - if (part.type === "reasoning") { - if (!part.text) return null; + if (g.kind === "reasoning") { return ( - {part.text} + {g.text} ); } - if (isToolPart(part)) { - return ( - - ); + if (g.kind === "single-tool") { + return ; } - return null; + // g.kind === "agent-tree" + return ; })} {isThisStreaming && !hasRenderableContent && ( diff --git a/components/tool-call/agent-tree.tsx b/components/tool-call/agent-tree.tsx new file mode 100644 index 0000000..7b36f0f --- /dev/null +++ b/components/tool-call/agent-tree.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { ToolPart, type ToolPartLike } from "./tool-part"; + +/** + * AgentTree renders a fan-out turn — when the orchestrator spawned 2+ + * agents in parallel on a single user prompt. Visually it's a header + * chip with the live agent counter ("3 of 4 agents working…") followed + * by a 2-column grid of agent cards. Each card surfaces its own state + * via its ToolHeader, so users see individual agents go from pulsing + * (running) to settled (complete) as their tool calls resolve. + * + * Used by HeroDemo when an assistant message contains 2+ contiguous + * tool parts. Single-tool responses render inline through ToolPart + * directly — no orchestrator framing needed. + */ +export function AgentTree({ tools }: { tools: ToolPartLike[] }) { + const total = tools.length; + const done = tools.filter( + (t) => t.state === "output-available" || t.state === "output-error", + ).length; + const errored = tools.filter((t) => t.state === "output-error").length; + const allDone = done === total; + + return ( +
+ +
+ {tools.map((part, idx) => ( + + ))} +
+ {!allDone && ( +
+ Synthesis incoming… +
+ )} +
+ ); +} + +function OrchestratorChip({ + total, + done, + errored, +}: { + total: number; + done: number; + errored: number; +}) { + const allDone = done === total; + const label = allDone + ? errored > 0 + ? `${total} agents · ${errored} unavailable` + : `${total} agents · complete` + : `${done} of ${total} agents working…`; + + return ( +
+ + + Orchestrator · {label} + +
+ ); +} + +/** + * Each agent branch is just a wrapped ToolPart with the brand surface + * (shadow-as-border, rounded corners, background). The ToolHeader inside + * the renderer handles its own state pulse + label. + */ +function AgentBranch({ part }: { part: ToolPartLike }) { + return ( +
+ +
+ ); +} diff --git a/components/tool-call/renderers/audience-agent-renderer.tsx b/components/tool-call/renderers/audience-agent-renderer.tsx new file mode 100644 index 0000000..98f7f81 --- /dev/null +++ b/components/tool-call/renderers/audience-agent-renderer.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { ArrowDownRight, ArrowUpRight, Minus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { + AudienceAgentOutput, + Market, +} from "@/lib/agent/tools/audience-agent"; + +/** + * Audience Agent card body. Three stacked sections: + * 1. Top 5 markets — city + country + listener count + trend arrow + * 2. Age split — horizontal stacked bar (18-24 / 25-34 / 35-44 / 45+) + * 3. Gender split — horizontal stacked bar (female / male / other) + * + * The stacked bars are deliberately achromatic — varying opacity steps + * instead of color — so the eye reads the proportions, not the palette. + * Brand stays monochrome; data is the splash of meaning. + */ +export function AudienceAgentRenderer({ + output, +}: { + output: AudienceAgentOutput; +}) { + const ageEntries = Object.entries(output.ageSplit) as Array< + [keyof AudienceAgentOutput["ageSplit"], number] + >; + const genderEntries = Object.entries(output.genderSplit) as Array< + [keyof AudienceAgentOutput["genderSplit"], number] + >; + + return ( +
+
+ {output.topMarkets.map((m) => ( + + ))} +
+ ({ key: k, value: v }))} + /> + ({ key: k, value: v }))} + /> +
+ ); +} + +function MarketRow({ market }: { market: Market }) { + const TrendIcon = + market.trend === "growth" + ? ArrowUpRight + : market.trend === "decline" + ? ArrowDownRight + : Minus; + const trendTone = + market.trend === "growth" + ? "text-(--foreground)/85" + : market.trend === "decline" + ? "text-(--foreground)/40" + : "text-(--foreground)/55"; + + return ( +
+
+ + {market.city} + + + {market.country} + +
+
+ + {formatListeners(market.listeners)} + + +
+
+ ); +} + +function SegmentBar({ + label, + segments, +}: { + label: string; + segments: { key: string; value: number }[]; +}) { + return ( +
+
+ + {label} + +
+ {segments.map((s) => ( + + {s.key} {Math.round(s.value * 100)}% + + ))} +
+
+
+ {segments.map((s, i) => ( +
+ ))} +
+
+ ); +} + +function formatListeners(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; + return `${n}`; +} diff --git a/components/tool-call/renderers/creator-agent-renderer.tsx b/components/tool-call/renderers/creator-agent-renderer.tsx new file mode 100644 index 0000000..d6264fa --- /dev/null +++ b/components/tool-call/renderers/creator-agent-renderer.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Instagram, Music2 } from "lucide-react"; +import type { + Creator, + CreatorAgentOutput, +} from "@/lib/agent/tools/creator-agent"; + +/** + * Creator Agent card body. 2-column grid of creator cards (matching the + * compact look of the similar-artists grid). Each card surfaces: + * - Platform icon (TikTok or IG) + * - Handle as the headline + * - Follower count in pixel-font + * - Audience-match percentage (the key signal — how well their + * followers overlap with the reference artist's listeners) + * - Recent music-post count as a "warmth" cue + */ +export function CreatorAgentRenderer({ + output, +}: { + output: CreatorAgentOutput; +}) { + return ( +
+ {output.creators.map((c) => ( + + ))} +
+ ); +} + +function CreatorCard({ creator }: { creator: Creator }) { + const PlatformIcon = creator.platform === "tiktok" ? Music2 : Instagram; + const matchPct = Math.round(creator.audienceMatch * 100); + + return ( +
+
+ + + {creator.handle} + +
+
+ + {formatFollowers(creator.followers)} · {matchPct}% match + +
+ {creator.recentMusicPosts > 0 && ( +
+ {creator.recentMusicPosts} music post + {creator.recentMusicPosts === 1 ? "" : "s"} · 30d +
+ )} +
+ ); +} + +function formatFollowers(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; + return `${n}`; +} diff --git a/components/tool-call/renderers/insights-agent-renderer.tsx b/components/tool-call/renderers/insights-agent-renderer.tsx new file mode 100644 index 0000000..749fa00 --- /dev/null +++ b/components/tool-call/renderers/insights-agent-renderer.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { + Insight, + InsightsAgentOutput, +} from "@/lib/agent/tools/insights-agent"; + +/** + * Insights Agent card body. Two stacked sections: + * 1. A monthly-listener sparkline showing the 12-month trajectory + * 2. 3-5 sentiment-tagged headline bullets ("Just added to RapCaviar", etc.) + * + * Each bullet is color-coded by sentiment (positive / neutral / warning) + * via a tiny status dot — keeps the body achromatic but signals the vibe. + */ +export function InsightsAgentRenderer({ + output, +}: { + output: InsightsAgentOutput; +}) { + const trend = output.monthlyListenerTrend; + const first = trend[0]?.listeners ?? 0; + const last = trend[trend.length - 1]?.listeners ?? 0; + const deltaPct = first ? ((last - first) / first) * 100 : 0; + + return ( +
+ +
+ {output.insights.map((insight, idx) => ( + + ))} +
+
+ ); +} + +function Sparkline({ + points, + deltaPct, + latest, +}: { + points: InsightsAgentOutput["monthlyListenerTrend"]; + deltaPct: number; + latest: number; +}) { + const min = Math.min(...points.map((p) => p.listeners)); + const max = Math.max(...points.map((p) => p.listeners)); + const range = Math.max(1, max - min); + const width = 200; + const height = 38; + const padX = 2; + + const path = points + .map((p, i) => { + const x = padX + (i / Math.max(1, points.length - 1)) * (width - padX * 2); + const y = height - 2 - ((p.listeners - min) / range) * (height - 6); + return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(" "); + + const deltaTone = + deltaPct >= 5 + ? "text-(--foreground)/85" + : deltaPct <= -5 + ? "text-(--foreground)/55" + : "text-(--foreground)/60"; + + return ( +
+
+ + {formatListeners(latest)} + + + {deltaPct >= 0 ? "+" : ""} + {deltaPct.toFixed(1)}% 12mo + +
+ + + +
+ ); +} + +const RECENCY_LABEL: Record = { + last_24h: "24h", + last_7d: "7d", + last_30d: "30d", + longer: "30d+", +}; + +function InsightRow({ insight }: { insight: Insight }) { + const dotTone = + insight.sentiment === "positive" + ? "bg-(--foreground)" + : insight.sentiment === "warning" + ? "bg-(--foreground)/45" + : "bg-(--foreground)/25"; + + return ( +
+ +
+
+ + {insight.headline} + + + {RECENCY_LABEL[insight.recency]} + +
+

+ {insight.detail} +

+
+
+ ); +} + +function formatListeners(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M listeners`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K listeners`; + return `${n} listeners`; +} diff --git a/components/tool-call/renderers/playlist-agent-renderer.tsx b/components/tool-call/renderers/playlist-agent-renderer.tsx new file mode 100644 index 0000000..c010e44 --- /dev/null +++ b/components/tool-call/renderers/playlist-agent-renderer.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { ExternalLink, Sparkles } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { + PlaylistAgentOutput, + PlaylistMatch, +} from "@/lib/agent/tools/playlist-agent"; + +/** + * Playlist Agent card body. A scroll-bounded vertical list of playlist + * matches sorted by the model's chosen ranking (the tool returns them + * already ranked). Each row shows the playlist name, curator, follower + * count, a horizontal fit-score bar, and — if the playlist accepts + * submissions — an external link icon hinting at the submission path. + * + * The fit-score bar is the visual anchor: zero-padding for full-bleed + * progress, achromatic so it reads as data rather than decoration. + */ +export function PlaylistAgentRenderer({ + output, +}: { + output: PlaylistAgentOutput; +}) { + // Cap the visible list at 6 — the tool may return 8-10, but the card + // gets tall fast inside the parallel-fan-out grid. + const visible = output.playlists.slice(0, 6); + + return ( +
+ {visible.map((p) => ( + + ))} + {output.playlists.length > visible.length && ( +
+ + {output.playlists.length - visible.length} more +
+ )} +
+ ); +} + +function PlaylistRow({ playlist }: { playlist: PlaylistMatch }) { + const fitPct = Math.round(playlist.fitScore * 100); + + return ( +
+ +
+
+ + {playlist.name} + + {playlist.recentlyAdded && ( + + )} +
+
+ {playlist.curator} + · + + {formatFollowers(playlist.followers)} + +
+
+ {playlist.submissionUrl && ( + + + + )} +
+ ); +} + +function FitBar({ fitPct }: { fitPct: number }) { + return ( +
+
+
= 80 + ? "bg-(--foreground)" + : fitPct >= 60 + ? "bg-(--foreground)/60" + : "bg-(--foreground)/30", + )} + style={{ width: `${fitPct}%` }} + /> +
+ + {fitPct}% + +
+ ); +} + +function formatFollowers(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; + return `${n}`; +} diff --git a/components/tool-call/tool-part.tsx b/components/tool-call/tool-part.tsx index 14ab4d7..145e12c 100644 --- a/components/tool-call/tool-part.tsx +++ b/components/tool-call/tool-part.tsx @@ -1,7 +1,15 @@ "use client"; import { ToolHeader, type ToolPartState } from "./tool-header"; +import { AudienceAgentRenderer } from "./renderers/audience-agent-renderer"; +import { CreatorAgentRenderer } from "./renderers/creator-agent-renderer"; +import { InsightsAgentRenderer } from "./renderers/insights-agent-renderer"; +import { PlaylistAgentRenderer } from "./renderers/playlist-agent-renderer"; import { SimilarArtistsRenderer } from "./renderers/similar-artists-renderer"; +import type { AudienceAgentOutput } from "@/lib/agent/tools/audience-agent"; +import type { CreatorAgentOutput } from "@/lib/agent/tools/creator-agent"; +import type { InsightsAgentOutput } from "@/lib/agent/tools/insights-agent"; +import type { PlaylistAgentOutput } from "@/lib/agent/tools/playlist-agent"; import type { SimilarArtistsOutput } from "@/lib/agent/tools/similar-artists"; /** @@ -19,8 +27,17 @@ export type ToolPartLike = { errorText?: string; }; +/** + * Maps each tool's `tool-` part type to a human-readable label + * shown in the agent's header chip. New agents added to the registry + * should register their label here too. + */ const TOOL_LABELS: Record = { - "tool-similar_artists": "Similar Artists", + "tool-similar_artists": "Sleeper Agent", + "tool-playlist_agent": "Playlist Agent", + "tool-audience_agent": "Audience Agent", + "tool-creator_agent": "Creator Agent", + "tool-insights_agent": "Insights Agent", }; export function isToolPart(part: { type: string }): boolean { @@ -30,20 +47,21 @@ export function isToolPart(part: { type: string }): boolean { /** * Dispatcher that routes a tool part from `msg.parts` to the matching * renderer. Lives flush — no outer card chrome — because the caller - * (HeroDemo's assistant message card) already provides the surface. + * (HeroDemo's assistant message card, or the AgentTree fan-out grid) + * already provides the surface. * * Unknown tool types fall through to a minimal JSON output so the demo * never throws on a tool we forgot to register a renderer for. */ export function ToolPart({ part }: { part: ToolPartLike }) { const label = - TOOL_LABELS[part.type] ?? part.type.replace(/^tool-/, "").replace(/_/g, " "); + TOOL_LABELS[part.type] ?? + part.type.replace(/^tool-/, "").replace(/_/g, " "); + const input = part.input as { artist?: string } | undefined; + const summary = input?.artist ? `for ${input.artist}` : undefined; if (part.type === "tool-similar_artists") { - const input = part.input as { artist?: string } | undefined; const output = part.output as SimilarArtistsOutput | undefined; - const summary = input?.artist ? `for ${input.artist}` : undefined; - return (
@@ -52,6 +70,47 @@ export function ToolPart({ part }: { part: ToolPartLike }) { ); } + if (part.type === "tool-playlist_agent") { + const output = part.output as PlaylistAgentOutput | undefined; + return ( +
+ + {output && } +
+ ); + } + + if (part.type === "tool-audience_agent") { + const output = part.output as AudienceAgentOutput | undefined; + return ( +
+ + {output && } +
+ ); + } + + if (part.type === "tool-creator_agent") { + const output = part.output as CreatorAgentOutput | undefined; + return ( +
+ + {output && } +
+ ); + } + + if (part.type === "tool-insights_agent") { + const output = part.output as InsightsAgentOutput | undefined; + return ( +
+ + {output && } +
+ ); + } + + // Fallback for unknown tool types — defensive only, shouldn't fire in prod. return (
diff --git a/lib/agent/system-prompt.ts b/lib/agent/system-prompt.ts index b0986c4..9870120 100644 --- a/lib/agent/system-prompt.ts +++ b/lib/agent/system-prompt.ts @@ -3,68 +3,87 @@ import { siteConfig } from "@/lib/config"; /** * System prompt for the marketing hero demo agent. * - * Designed for a short, demo-shaped conversation: the user asks a music - * question, the agent calls a tool, the UI renders a rich card, and the - * agent writes ONE short sentence framing the result. + * Architecture: a single LLM acts as an *orchestrator* that fans out to + * specialized "agent" tools in parallel based on the user's intent. The + * agent loop has a generous step budget so the model can (1) emit multiple + * tool calls in one step, (2) receive all results, and (3) write a + * synthesis sentence in a second step. * - * Keep tool routing rules explicit. The hero demo is high-traffic and - * latency-sensitive — we want the model to call tools immediately rather - * than narrate its plan first. + * For the user experience to feel like a hero-demo, the model must: + * - Fan out (call multiple tools in one step) when the prompt is broad + * - Single-shot (call one tool) when the prompt is narrow + * - Never narrate "I'm going to call these tools…" before doing it + * - Tie all parallel agent results together in a single synthesis line */ -export const RECOUP_DEMO_SYSTEM_PROMPT = `You are the ${siteConfig.name} agent, embedded in the marketing site hero demo. +export const RECOUP_DEMO_SYSTEM_PROMPT = `You are the ${siteConfig.name} orchestrator, embedded in the marketing site hero demo. -${siteConfig.name} is an AI-native music intelligence platform for artists, labels, distributors, and catalog owners. +${siteConfig.name} is an AI-native music intelligence platform for artists, labels, distributors, and catalog owners. The demo shows what happens when a single prompt spawns multiple specialized agents in parallel. -# Tools +# Agents you can spawn -- \`similar_artists\` — Returns up to 6 sonically and audience-adjacent artists from Recoup's similarity model. Each artist comes with: monthly Spotify listeners, follower count, primary genre, career stage (superstar/mainstream/mid_level/developing/early), recent momentum (growth/stable/decline), label, and a 0-1 similarity score. Use whenever the user asks for similar artists, comparable acts, alternatives, sonic neighbors, or anything in that family. +You have five specialist agents available. Each is a tool you can call. Multiple agents can — and SHOULD — be called in parallel in a single step when the prompt benefits from it. -# Tool routing rules +- \`similar_artists\` — **Sleeper Agent**. Returns up to 6 sonically and audience-adjacent artists with monthly listeners, similarity score, career stage, momentum, label. Use when the user wants comps, neighbors, sonic adjacents, or "find me artists like X". +- \`playlist_agent\` — **Playlist Agent**. Returns up to 10 playlist-fit matches (editorial + indie) with curator, follower count, fit score, submission URLs where available. Use when the user wants playlists, pitching, placement, or to launch a song. +- \`audience_agent\` — **Audience Agent**. Returns top 5 cities with trend direction, age + gender split, total monthly listeners. Use when the user wants audience intel, demographics, fans, geography, top markets. +- \`creator_agent\` — **Creator Agent**. Returns up to 6 TikTok / IG creators with audience-match score, recent music-post count, follower count. Use when the user wants promo creators, influencers, TikTok seeding, viral outreach. +- \`insights_agent\` — **Insights Agent**. Returns 3-5 sentiment-tagged highlights + a 12-month listener trend. Use when the user wants what's happening now, recent activity, trends, growth trajectory. -- When a request maps cleanly to a tool, call it IMMEDIATELY. Do not narrate what you are about to do. -- Pass the artist name exactly as the user mentioned it. -- After the tool returns, write ONE short sentence (≤25 words) framing the result. Use the rich structured data in the tool output — pick out a momentum trend, a sleeper, or a label-mate worth flagging. Do NOT re-list the artists; the UI already shows them. - - Good close: "Strong overlap, but Snoh Aalegra is the sleeper here — superstar lane, rising momentum." - - Good close: "Three OVO-adjacent picks plus two younger inheritors of the melodic-trap formula." - - Bad close: "Here are 6 similar artists." (too generic; don't waste the close sentence) -- If the request does not map to a tool, answer in plain prose under 80 words. +# Orchestration rules -# Available demo capabilities (DO NOT OVER-PROMISE) +**Fan out when the prompt is open-ended.** Multi-agent fan-out is the demo's "wow" moment — embrace it whenever the user's intent maps to more than one agent. -The marketing demo only has the \`similar_artists\` tool. You can also analyze the data already returned by a prior \`similar_artists\` call without calling the tool again (e.g. "who's the sleeper?", "list the independents", "rank by momentum"). EVERYTHING ELSE — playlist building, campaign briefs, audience overlap analysis, tour planning, sync pitching, growth forecasts, pitch decks — is NOT available in this demo. +| Intent pattern | Spawn these agents in parallel | +|---|---| +| "Help me launch [artist]'s single" | playlist_agent + audience_agent + creator_agent + insights_agent (4-way fan-out) | +| "Tell me everything about [artist]" | audience_agent + insights_agent + similar_artists + creator_agent (4-way fan-out) | +| "Plan a campaign for [artist]" | playlist_agent + creator_agent + audience_agent + insights_agent (4-way fan-out) | +| "Who should [artist] tour with" | similar_artists + audience_agent (2-way) | +| "Find similar to [artist]" | similar_artists (1) | +| "Who's listening to [artist]" | audience_agent (1) | +| "What's new with [artist]" | insights_agent (1) | +| "Playlist ideas for [artist]" | playlist_agent (1) | -When the user asks for something the demo can't do: -1. Don't apologize and don't say "I can't". -2. Briefly acknowledge the request (one phrase). -3. Pivot to what \`similar_artists\` *can* show, framed as a useful next step. -4. Optionally hint that the full Recoup product at chat.recoupable.com handles the deeper analysis — but ONLY as a single phrase, not a sales pitch. +**Call all relevant agents in a SINGLE step.** Do NOT call one agent, wait for its result, then call another. Emit all tool_calls together so they fire in parallel — the UI is designed to render them as a fan-out tree. -Example close-sentence handoffs (use the working surface, never the missing one): -- ✅ "Want me to find the sleeper in this list?" -- ✅ "Should I dig into Brent Faiyaz next?" -- ✅ "Want me to filter to the independents?" -- ❌ "Want me to build a playlist from these?" — playlist building isn't in this demo -- ❌ "Should I sketch a release campaign?" — campaigns aren't in this demo -- ❌ "Want me to map their tour?" — tour data isn't in this demo +**Never narrate the plan.** Don't write "Let me check the audience and playlists for you…" before calling tools. Just call them. The UI shows the agents spinning up. -# Context resolution (conversational memory) +# Synthesis sentence (after all agents return) -The conversation may span multiple turns. The full prior tool output (every artist's listener counts, momentum, label, similarity score, etc.) is available to you in the message history — **do not re-call the tool just to re-read data you already have**. Analyze the prior result directly and answer in prose. +After the parallel agents complete, write ONE synthesis sentence (≤35 words) that **ties together findings across multiple agents into a single story**. Use specific data points. Do not re-list what's already in the cards. -Cases: +- ✅ "East Coast act with Brazilian upside — RapCaviar just added them, 4 TikTok creators are warm, Snoh Aalegra is the bridge to alt-R&B." +- ✅ "Strong momentum despite softening TikTok signal — playlist coverage is at an all-time high, indie curators in Brazil are the next play." +- ❌ "Here are the playlists, audience, creators, and insights." (lists agents, says nothing) +- ❌ "I found 8 playlists, 5 markets, 4 creators, and 5 insights." (recites counts) +- ❌ "Let me know what you'd like to explore next." (no insight) -- **Analytical follow-ups about prior results** ("who's the sleeper?", "show only independents", "who's rising fastest?", "rank by momentum") → answer from memory, by name, citing one specific stat per artist. Do NOT call the tool. Do NOT re-list everyone — give a tight prose answer (≤60 words). Examples: - - "Snoh Aalegra — superstar lane, rising momentum, but only 7M monthly listeners. Most upside per current reach." - - "Just two: Brent Faiyaz and Frank Ocean. Both independent, both rising. Faiyaz at 22M, Ocean at 40M." -- **Drill-down with a new tool call** ("more like them", "find similar to [Brent Faiyaz]", "what about [name]") → call \`similar_artists\` again with the chosen artist as the new reference. -- **Pronoun resolution**: "their", "them", "those", "these" → the artists in the last \`similar_artists\` result; "the [Nth] one" → that specific artist by index; "that artist" → the reference artist from the most recent call. -- **Bare continuation** like "what else?" or "keep going" → call \`similar_artists\` again on the same reference, framing it as a deeper second pass. +# Single-tool responses -If a follow-up needs data we don't have a tool for (tour data, audience overlap, growth forecast, playlist building, campaign briefs), don't apologize — pivot to what \`similar_artists\` *can* show, and pitch the deeper analysis as a one-phrase teaser for chat.recoupable.com. +When the prompt maps to just one agent, call that agent and write a tight ≤25-word framing sentence (same rules as before — use the rich structured data, pick out something specific, don't re-list). + +# Context resolution (conversational memory across turns) + +Prior tool outputs are in your context window. **Do not re-call a tool to re-read data you already have.** + +- **Analytical follow-ups** ("who's the sleeper?", "show only independents", "rank by momentum", "which market is growing fastest?") → answer from memory in ≤60 words, by name, with one specific stat per item. Do NOT call the tool again. +- **Drill-down with a new tool call** ("more like Brent Faiyaz", "audience for Snoh Aalegra", "playlists for Tyla") → call the appropriate agent on the new reference. Single tool, not fan-out, unless the drill is also broad. +- **Pronoun resolution**: "their", "them", "those" → entities in the most recent agent result; "the [Nth] one" → by index; "that artist" → the reference of the most recent call. +- **Bare continuation** like "what else?" or "keep going" → re-spawn the most recent agent on the same reference for a second pass. + +# Capabilities NOT in the demo (do not over-promise) + +You have five agents only: similar_artists, playlist_agent, audience_agent, creator_agent, insights_agent. EVERYTHING ELSE — tour routing, sync pitching, brand matching, growth forecasts, A&R scoring, catalog valuation, content generation, royalty analysis, contract review — is NOT in this demo. + +When the user asks for something out-of-scope: +1. Don't apologize. Don't say "I can't". +2. Acknowledge in one phrase. +3. Pivot to what your agents *can* show. +4. Optionally hint that the full Recoup product at chat.recoupable.com handles deeper analysis — ONE phrase, not a sales pitch. # Voice -- Plain prose. No headers, no bullets, no markdown lists. -- Keep all responses under 100 words. -- End with a soft handoff to a working demo capability (e.g., "Want me to find the sleeper here?", "Should I dig into [artist] next?", "Want me to filter to the independents?"). See the "Available demo capabilities" section for the full list of allowed handoffs. +- Plain prose. No headers, no bullets, no markdown lists in responses. +- Synthesis sentences ≤35 words. Single-tool framing sentences ≤25 words. Analytical follow-ups ≤60 words. +- End with a soft handoff that points at a working agent ("Want me to find the sleeper in this list?", "Want playlists for Brent Faiyaz?", "Should I dig into who's listening to Tyla?"). - Never apologize. Never say "as an AI".`; diff --git a/lib/agent/tools/audience-agent.ts b/lib/agent/tools/audience-agent.ts new file mode 100644 index 0000000..dc5f547 --- /dev/null +++ b/lib/agent/tools/audience-agent.ts @@ -0,0 +1,82 @@ +import { tool } from "ai"; +import { z } from "zod"; + +const audienceAgentInputSchema = z.object({ + artist: z + .string() + .min(1) + .describe("Artist name to pull audience intelligence for"), +}); + +const marketSchema = z.object({ + city: z.string(), + country: z.string(), + listeners: z.number().int().nonnegative(), + trend: z + .enum(["growth", "stable", "decline"]) + .describe("30-day listener trend in this market"), +}); + +const audienceAgentOutputSchema = z.object({ + reference: z.string(), + totalMonthlyListeners: z.number().int().nonnegative(), + topMarkets: z.array(marketSchema).min(1).max(5), + ageSplit: z.object({ + "18-24": z.number().min(0).max(1), + "25-34": z.number().min(0).max(1), + "35-44": z.number().min(0).max(1), + "45+": z.number().min(0).max(1), + }), + genderSplit: z.object({ + female: z.number().min(0).max(1), + male: z.number().min(0).max(1), + other: z.number().min(0).max(1), + }), +}); + +export type AudienceAgentOutput = z.infer; +export type Market = AudienceAgentOutput["topMarkets"][number]; + +const MOCK_AUDIENCE: Record = { + sza: { + reference: "SZA", + totalMonthlyListeners: 69_937_628, + topMarkets: [ + { city: "New York", country: "US", listeners: 4_120_000, trend: "stable" }, + { city: "Los Angeles", country: "US", listeners: 3_870_000, trend: "growth" }, + { city: "São Paulo", country: "BR", listeners: 2_640_000, trend: "growth" }, + { city: "London", country: "GB", listeners: 2_180_000, trend: "stable" }, + { city: "Mexico City", country: "MX", listeners: 1_920_000, trend: "growth" }, + ], + ageSplit: { "18-24": 0.42, "25-34": 0.36, "35-44": 0.16, "45+": 0.06 }, + genderSplit: { female: 0.61, male: 0.36, other: 0.03 }, + }, +}; + +function getMockAudience(reference: string): AudienceAgentOutput { + const key = reference.toLowerCase().split(/\s+/)[0]; + return { + ...(MOCK_AUDIENCE[key] ?? MOCK_AUDIENCE["sza"]), + reference, + }; +} + +/** + * Audience Agent — pulls demographics + geographic concentration for an + * artist. Surfaces top 5 cities with trend direction, age split, + * gender split, and total monthly listeners. + * + * Day 1 ships with mock data so we can build the parallel-fan-out UI + * without juggling endpoint quirks. Day 4 swaps to + * `/research/audience?artist=...` + `/research/cities?artist=...` + * against the live Recoup API. + */ +export const audienceAgentTool = tool({ + description: + "Pull audience intelligence for an artist: top 5 cities with 30-day trend direction, age and gender splits, and total monthly listeners. Use whenever the user asks about audience, demographics, fans, where their listeners live, top markets, or who's listening.", + inputSchema: audienceAgentInputSchema, + outputSchema: audienceAgentOutputSchema, + execute: async ({ artist }) => { + return getMockAudience(artist); + }, +}); diff --git a/lib/agent/tools/creator-agent.ts b/lib/agent/tools/creator-agent.ts new file mode 100644 index 0000000..8cab781 --- /dev/null +++ b/lib/agent/tools/creator-agent.ts @@ -0,0 +1,97 @@ +import { tool } from "ai"; +import { z } from "zod"; + +const creatorAgentInputSchema = z.object({ + artist: z + .string() + .min(1) + .describe("Artist name to find audience-matched creators for"), +}); + +const creatorSchema = z.object({ + handle: z.string(), + platform: z.enum(["tiktok", "instagram"]), + followers: z.number().int().nonnegative(), + audienceMatch: z + .number() + .min(0) + .max(1) + .describe("0-1 audience-overlap score with the reference artist's listeners"), + recentMusicPosts: z + .number() + .int() + .nonnegative() + .describe("Count of music-related posts in last 30 days"), + avatarUrl: z.string().url().nullable(), +}); + +const creatorAgentOutputSchema = z.object({ + reference: z.string(), + creators: z.array(creatorSchema).min(1).max(6), +}); + +export type CreatorAgentOutput = z.infer; +export type Creator = CreatorAgentOutput["creators"][number]; + +const MOCK_CREATORS: Record = { + sza: [ + { + handle: "@notlinawaithe", + platform: "tiktok", + followers: 2_100_000, + audienceMatch: 0.94, + recentMusicPosts: 12, + avatarUrl: null, + }, + { + handle: "@kennedy.eurich", + platform: "tiktok", + followers: 1_400_000, + audienceMatch: 0.91, + recentMusicPosts: 8, + avatarUrl: null, + }, + { + handle: "@aliyahsinterlude", + platform: "instagram", + followers: 880_000, + audienceMatch: 0.93, + recentMusicPosts: 6, + avatarUrl: null, + }, + { + handle: "@theshyloft", + platform: "tiktok", + followers: 320_000, + audienceMatch: 0.89, + recentMusicPosts: 14, + avatarUrl: null, + }, + ], +}; + +function getMockCreators(reference: string): CreatorAgentOutput { + const key = reference.toLowerCase().split(/\s+/)[0]; + const creators = MOCK_CREATORS[key] ?? MOCK_CREATORS["sza"]; + return { reference, creators }; +} + +/** + * Creator Agent — surfaces TikTok and Instagram creators whose audience + * profile overlaps with the artist's listeners. Ranked by audience-match + * percentage, with recent music-post count as a "warm-lead" signal. + * + * Day 1 ships with mock data so we can build the parallel-fan-out UI + * without juggling endpoint quirks. Day 4 swaps to + * `/research/people?artist=...` + `/research/instagram-posts?artist=...` + * against the live Recoup API. + */ +export const creatorAgentTool = tool({ + description: + "Find TikTok and Instagram creators whose audience profile overlaps with the artist's listeners — useful for promo outreach, influencer matching, and viral seeding. Returns up to 6 creators ranked by audience-match score, with handle, platform, follower count, and recent music-post count as a warm-lead signal. Use when the user asks about creators, influencers, TikTok promo, IG promo, virality, or seeding a song.", + inputSchema: creatorAgentInputSchema, + outputSchema: creatorAgentOutputSchema, + execute: async ({ artist }) => { + return getMockCreators(artist); + }, +}); diff --git a/lib/agent/tools/index.ts b/lib/agent/tools/index.ts index d9fedcd..5671b64 100644 --- a/lib/agent/tools/index.ts +++ b/lib/agent/tools/index.ts @@ -1,14 +1,26 @@ +import { audienceAgentTool } from "./audience-agent"; +import { creatorAgentTool } from "./creator-agent"; +import { insightsAgentTool } from "./insights-agent"; +import { playlistAgentTool } from "./playlist-agent"; import { similarArtistsTool } from "./similar-artists"; /** * Tool registry for the marketing hero demo agent. * - * Keys here become the tool names exposed to the LLM. They also become the - * part type discriminator on the client side (e.g. `similar_artists` here - * surfaces as `tool-similar_artists` parts in UIMessage.parts). + * Each key here becomes the tool name exposed to the LLM and the part-type + * discriminator on the client side (e.g. `playlist_agent` here surfaces as + * `tool-playlist_agent` parts in UIMessage.parts). + * + * Naming convention: `_agent` for the new fan-out agents so the UI + * can render each as a labeled specialist. `similar_artists` stays as-is + * since it predates the agent framing (it's the "Sleeper Agent" in the UI). */ export const demoTools = { similar_artists: similarArtistsTool, + playlist_agent: playlistAgentTool, + audience_agent: audienceAgentTool, + creator_agent: creatorAgentTool, + insights_agent: insightsAgentTool, } as const; export type DemoToolName = keyof typeof demoTools; diff --git a/lib/agent/tools/insights-agent.ts b/lib/agent/tools/insights-agent.ts new file mode 100644 index 0000000..8602f81 --- /dev/null +++ b/lib/agent/tools/insights-agent.ts @@ -0,0 +1,116 @@ +import { tool } from "ai"; +import { z } from "zod"; + +const insightsAgentInputSchema = z.object({ + artist: z + .string() + .min(1) + .describe("Artist name to pull noteworthy highlights + trends for"), +}); + +const insightSchema = z.object({ + headline: z.string().describe("One-line takeaway"), + detail: z + .string() + .describe("One sentence of supporting context with a specific stat"), + sentiment: z + .enum(["positive", "neutral", "warning"]) + .describe("Color-codes the insight in the UI"), + recency: z + .enum(["last_24h", "last_7d", "last_30d", "longer"]) + .describe("When the underlying event happened"), +}); + +const trendPointSchema = z.object({ + month: z.string().describe("YYYY-MM"), + listeners: z.number().int().nonnegative(), +}); + +const insightsAgentOutputSchema = z.object({ + reference: z.string(), + insights: z.array(insightSchema).min(1).max(5), + monthlyListenerTrend: z.array(trendPointSchema).min(2).max(12), +}); + +export type InsightsAgentOutput = z.infer; +export type Insight = InsightsAgentOutput["insights"][number]; + +const MOCK_INSIGHTS: Record = { + sza: { + reference: "SZA", + insights: [ + { + headline: "Just added to RapCaviar", + detail: "Added 2 hours ago, currently at position #4 with 14.2M reach.", + sentiment: "positive", + recency: "last_24h", + }, + { + headline: "São Paulo audience is breaking out", + detail: "Brazil listeners +34% MoM — fastest-growing market in the top 5.", + sentiment: "positive", + recency: "last_30d", + }, + { + headline: "Catalog reach hit a new high", + detail: "Total Spotify playlist reach crossed 610M — first time over that line.", + sentiment: "positive", + recency: "last_7d", + }, + { + headline: "TikTok engagement softening", + detail: "Music-tagged posts -12% week-over-week despite stable listener count.", + sentiment: "warning", + recency: "last_7d", + }, + { + headline: "Stable in core US markets", + detail: "NY + LA listener counts flat — no decay, no growth in the home markets.", + sentiment: "neutral", + recency: "last_30d", + }, + ], + monthlyListenerTrend: [ + { month: "2025-06", listeners: 62_400_000 }, + { month: "2025-07", listeners: 63_100_000 }, + { month: "2025-08", listeners: 64_800_000 }, + { month: "2025-09", listeners: 65_300_000 }, + { month: "2025-10", listeners: 66_100_000 }, + { month: "2025-11", listeners: 67_900_000 }, + { month: "2025-12", listeners: 68_400_000 }, + { month: "2026-01", listeners: 69_000_000 }, + { month: "2026-02", listeners: 69_400_000 }, + { month: "2026-03", listeners: 69_600_000 }, + { month: "2026-04", listeners: 69_800_000 }, + { month: "2026-05", listeners: 69_937_628 }, + ], + }, +}; + +function getMockInsights(reference: string): InsightsAgentOutput { + const key = reference.toLowerCase().split(/\s+/)[0]; + return { + ...(MOCK_INSIGHTS[key] ?? MOCK_INSIGHTS["sza"]), + reference, + }; +} + +/** + * Insights Agent — surfaces trending highlights, recent activity, and + * a 12-month listener trend for an artist. Each insight is sentiment-tagged + * (positive / neutral / warning) and recency-tagged so the UI can prioritize. + * + * Day 1 ships with mock data so we can build the parallel-fan-out UI + * without juggling endpoint quirks. Day 4 swaps to + * `/research/insights?artist=...` + `/research/metrics?artist=...` + * against the live Recoup API. + */ +export const insightsAgentTool = tool({ + description: + "Pull noteworthy highlights, recent activity, and a 12-month listener trend for an artist. Returns 3-5 sentiment-tagged insights (playlist adds, audience trend shifts, TikTok signals, market growth/decline) plus a monthly listener time series. Use whenever the user asks what's happening with an artist, what's new, what's trending, what to watch out for, recent activity, or growth trajectory.", + inputSchema: insightsAgentInputSchema, + outputSchema: insightsAgentOutputSchema, + execute: async ({ artist }) => { + return getMockInsights(artist); + }, +}); diff --git a/lib/agent/tools/playlist-agent.ts b/lib/agent/tools/playlist-agent.ts new file mode 100644 index 0000000..80a81c9 --- /dev/null +++ b/lib/agent/tools/playlist-agent.ts @@ -0,0 +1,125 @@ +import { tool } from "ai"; +import { z } from "zod"; + +const playlistAgentInputSchema = z.object({ + artist: z + .string() + .min(1) + .describe("Artist name to find playlist-fit matches for"), +}); + +const playlistItemSchema = z.object({ + name: z.string(), + curator: z.string(), + followers: z.number().int().nonnegative(), + fitScore: z + .number() + .min(0) + .max(1) + .describe("0-1 estimated playlist fit based on genre + audience + sonic match"), + recentlyAdded: z + .boolean() + .describe("True if this playlist added artists in the last 30 days"), + submissionUrl: z.string().url().nullable(), +}); + +const playlistAgentOutputSchema = z.object({ + reference: z.string(), + playlists: z.array(playlistItemSchema).min(1).max(10), +}); + +export type PlaylistAgentOutput = z.infer; +export type PlaylistMatch = PlaylistAgentOutput["playlists"][number]; + +const MOCK_PLAYLISTS: Record = { + sza: [ + { + name: "RapCaviar", + curator: "Spotify", + followers: 14_200_000, + fitScore: 0.97, + recentlyAdded: true, + submissionUrl: null, + }, + { + name: "Are & Be", + curator: "Spotify", + followers: 5_100_000, + fitScore: 0.96, + recentlyAdded: true, + submissionUrl: null, + }, + { + name: "Mood Booster", + curator: "Spotify", + followers: 8_700_000, + fitScore: 0.84, + recentlyAdded: false, + submissionUrl: null, + }, + { + name: "Chilled R&B", + curator: "Apple Music", + followers: 1_400_000, + fitScore: 0.95, + recentlyAdded: true, + submissionUrl: null, + }, + { + name: "Slow R&B", + curator: "Indie · Cookie", + followers: 420_000, + fitScore: 0.93, + recentlyAdded: true, + submissionUrl: "https://submithub.com/playlist/slow-rnb", + }, + { + name: "Velvet Dreams", + curator: "Indie · Lush Sounds", + followers: 86_000, + fitScore: 0.91, + recentlyAdded: true, + submissionUrl: "https://submithub.com/playlist/velvet-dreams", + }, + { + name: "Late Night Soul", + curator: "Indie · After Hours", + followers: 64_000, + fitScore: 0.88, + recentlyAdded: false, + submissionUrl: "https://submithub.com/playlist/late-night-soul", + }, + { + name: "Yoga & Meditation", + curator: "Spotify", + followers: 6_300_000, + fitScore: 0.42, + recentlyAdded: false, + submissionUrl: null, + }, + ], +}; + +function getMockPlaylists(reference: string): PlaylistAgentOutput { + const key = reference.toLowerCase().split(/\s+/)[0]; + const matches = MOCK_PLAYLISTS[key] ?? MOCK_PLAYLISTS["sza"]; + return { reference, playlists: matches }; +} + +/** + * Playlist Agent — finds editorial + indie playlists that fit an artist's + * genre/audience profile, with submission paths for the indie ones. + * + * Day 1 ships with mock data so we can build the parallel-fan-out UI + * without juggling endpoint quirks. Day 4 swaps to + * `/research/playlists?artist=...` against the live Recoup API. + */ +export const playlistAgentTool = tool({ + description: + "Scan editorial and indie playlists for fit with a given artist's genre, audience, and sonic profile. Returns up to 10 ranked playlist matches with curator, follower count, a 0-1 fit score, whether they've added new artists recently, and submission URLs where available. Use when the user asks about playlists, pitching, placement opportunities, or launching a song/single.", + inputSchema: playlistAgentInputSchema, + outputSchema: playlistAgentOutputSchema, + execute: async ({ artist }) => { + return getMockPlaylists(artist); + }, +});