Okay, let's ask our system something I've genuinely been thinking about recently: which are better - gas, electric or induction hobs? We can see that it produces a plan here and then produces the queries. And finally, we get back a really nice summary of all the information.
Interestingly, we can even ask a follow-up here. We can ask, "are gas hobs dangerous?" And it will integrate the previous answer into its answer. We can see this even better if I just ask a vague question - it will understand that I'm talking about electric hobs because our entire conversation has been about hobs.
So let's start with my generateQueriesForTavily function. This is essentially a query rewriter, which is a well-known term that you can go out and Google have a look at. It takes in a conversation history or a question and turns it into queries.
const generateQueriesForTavily = (modelMessages: ModelMessage[]) => {const queriesResult = streamObject({model: google("gemini-2.0-flash"),system: `You are a helpful assistant that generates queries to search the web for information.
And I've applied the prompt template here:
You're a helpful assistant that generates queries to search the web for information.
We've got some rules here, we've got "three to five queries", and I'm replying with an output format too. Because I'm specifying the output format, I don't feel the need to add z.describe onto the plan or the queries, but of course you could experiment with using z.describe instead of the output format block there. And then finally we just add in messages, our model messages.
Next up, let's go down to our streamQueriesToFrontend function. I'll gloss over this because we've done this a few times now. We're monitoring the partialObjectStream and we're writing data-queries parts with the queries part ID.
const streamQueriesToFrontend = async (queriesResult: ReturnType<typeof generateQueriesForTavily>,writer: UIMessageStreamWriter<MyMessage>) => {const queriesPartId = crypto.randomUUID()const planPartId = crypto.randomUUID
Same thing for the plan down below. And because we're doing that we have the plan and the queries being streamed in. Beautiful.
Finally we have our streamFinalSummary function. I'm doing a fair chunk of work here to format the sources into XML where we have a bunch of search-query tags here, each with an index and a query attached. Then we have an array of results inside that are formatted in XML tag of result. Those formatted sources then get put into the answer results down here.
We have some output formats, we have our sources, we have a bunch of rules. And so this prompt is very similar looking to a bunch of the prompts that we've seen so far. Finally then we merge it into the UI message stream with a sendStart: false.
What I like about this research workflow is there are lots of very obvious ways that it can be improved. For instance, what if we ask our system a question that obviously does not need a search tool to answer? Like, "hey, sup?" The plan becomes:
The user greeted me, I will generate some queries about common greetings and their origins to respond in a more engaging manner.
Whereas probably what should have happened is it should have just said "sup". In other words, we probably need a router before our workflow runs to check if we even need to run the workflow. This is without considering things like guardrails or whether we want to modify the number of queries that are generated or search results that are synthesized based on the type of query that comes in.
There's a lot of UI stuff we could improve here too. Why aren't we syncing the sources from the back end to the front end? It would be really really great just to see an overlay of all of the websites that have been visited while going through this query.
So I recommend you take this opportunity to just go nuts on this project.
Do a ton of prompt engineering so you get the outputs that you want and eval them because most AI applications out there will need some kind of research workflow and so building up one from scratch and tailoring it to your needs is a really great exercise.
So good luck, have fun and I will see you in the next one.
const streamFinalSummary = async (searchResults: Awaited<ReturnType<typeof callTavilyToGetSearchResults>>,messages: ModelMessage[],writer: UIMessageStreamWriter<MyMessage>) => {const formattedSources = searchResults.map(({ response, query }, i) => {return `<search-query index="${i + 1}" query="${query}">${response.results.map((res, j) => {return `<result index="${j + 1}"><title>${res.title}</title><url>${res.url ?? "#"}</url><content>${res.content ?? ""}</content></result>`}).join("\n")}</search-query>`}).join("\n")const answerResult = streamText({model: google("gemini-2.0-flash"),system: `You are a helpful assistant that answers questions based on the search results.<rules>You should use the search results to answer the question.Use sources from the search results to answer the question.Sources should be cited as markdown links.<markdown-link-example>You might consider looking at [this article](https://www.example.com) to answer the question.</markdown-link-example>Sources should not be cited with the full URL visible to the user. Instead, use a short description of the source:<bad-markdown-link-example>This site will be useful to you: [https://www.example.com](https://www.example.com)</bad-markdown-link-example></rules><sources>${formattedSources}</sources><output-format>Use markdown formatting.</output-format>`,messages,})writer.merge(answerResult.toUIMessageStream({ sendStart: false }))}