Skip to content
Open
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
45 changes: 42 additions & 3 deletions app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { FeatureCollection } from 'geojson'
import { Spinner } from '@/components/ui/spinner'
import { Section } from '@/components/section'
import { FollowupPanel } from '@/components/followup-panel'
import { inquire, researcher, taskManager, querySuggestor, resolutionSearch } from '@/lib/agents'
import { inquire, researcher, taskManager, querySuggestor, resolutionSearch, toolCoordinator, executeToolPlan, aggregateToolResults } from '@/lib/agents'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Fix syntax around researcher call and preserve coordinator tool results

There are two separate issues in this block:

  1. Broken syntax (parse error) around researcher call
    Lines 365‑374 contain stray characters (c, )cAPI,, duplicated !useToolCoordinator), which Biome also flags. This makes the file invalid TypeScript/JS.

    You likely intended the loop body to look like this:

-    while (
-      useSpecificAPI
-        ? answer.length === 0
-        : answer.length === 0 && !errorOccurred
-    ) {
-      // If coordinator was used, pass finalMessages and disable tools for researcher
-          c    const { fullResponse, hasError, toolResponses } = await researcher(
-      currentSystemPrompt,
-      uiStream,
-      streamText,
-      finalMessages,
-      useSpecificAPI,
-      !useToolCoordinator // Pass a flag to disable tools if coordinator was used
-    )cAPI,
-        !useToolCoordinator // Pass a flag to disable tools if coordinator was used
-      )   !useToolCoordinator // Pass a flag to disable tools if coordinator was used
-      )
+    while (
+      useSpecificAPI
+        ? answer.length === 0
+        : answer.length === 0 && !errorOccurred
+    ) {
+      // If coordinator was used, pass finalMessages and disable tools for researcher
+      const { fullResponse, hasError, toolResponses } = await researcher(
+        currentSystemPrompt,
+        uiStream,
+        streamText,
+        finalMessages,
+        useSpecificAPI,
+        !useToolCoordinator // Pass a flag to disable tools if coordinator was used
+      )
  1. Coordinator results are lost before being persisted
    • When useToolCoordinator is true, you correctly set toolOutputs = results from executeToolPlan(plan).
    • But inside the loop you unconditionally do toolOutputs = toolResponses.
    • In the coordinator path, researcher is called with useTools = !useToolCoordinatorfalse, so toolResponses is always []. That overwrites the actual coordinator results.
    • As a result, the block that persists tool outputs to aiState:
if (toolOutputs.length > 0) {
  toolOutputs.map(output => {
    aiState.update({ ... })
  })
}

never sees the coordinator’s tool results.

To fix this while keeping current behavior for non‑coordinator runs, guard the assignment:

-      answer = fullResponse
-      toolOutputs = toolResponses
+      answer = fullResponse
+      if (!useToolCoordinator) {
+        toolOutputs = toolResponses
+      }

Together with the cleaned‑up call, that will:

  • Restore valid syntax.
  • Ensure that, when the coordinator is enabled, toolOutputs continues to refer to the executed plan’s results and gets persisted into aiState as intended, while non‑coordinator flows keep using researcher’s tool responses.

After applying the fixes, re‑run your linter/formatter and verify:

  • The file parses cleanly.
  • With USE_TOOL_COORDINATOR=true, you see tool messages in saved chats corresponding to the coordinator’s executed steps, and with it false you still see the regular streaming-tool behavior.

Also applies to: 128-129, 323-357, 359-379


🏁 Script executed:

#!/bin/bash
# Inspect the actual code around the syntax errors at lines 320-380
cat -n app/actions.tsx | sed -n '320,380p'

Length of output: 2626


🏁 Script executed:

#!/bin/bash
# Check lines around USE_TOOL_COORDINATOR flag (128-129)
cat -n app/actions.tsx | sed -n '125,135p'

Length of output: 513


🏁 Script executed:

#!/bin/bash
# Check the toolOutputs assignment pattern in the file
rg -n 'toolOutputs\s*=' app/actions.tsx -C2

Length of output: 362


Fix syntax errors in researcher call and preserve coordinator results

Lines 365–375 contain malformed syntax blocking file parsing:

  • Line 365: stray c before const
  • Line 372: malformed )cAPI,
  • Lines 373–375: duplicated and misplaced !useToolCoordinator comments with extra closing parentheses

Clean up the call to match the intended function signature:

    while (
      useSpecificAPI
        ? answer.length === 0
        : answer.length === 0 && !errorOccurred
    ) {
      // If coordinator was used, pass finalMessages and disable tools for researcher
-          c    const { fullResponse, hasError, toolResponses } = await researcher(
-      currentSystemPrompt,
-      uiStream,
-      streamText,
-      finalMessages,
-      useSpecificAPI,
-      !useToolCoordinator // Pass a flag to disable tools if coordinator was used
-    )cAPI,
-        !useToolCoordinator // Pass a flag to disable tools if coordinator was used
-      )   !useToolCoordinator // Pass a flag to disable tools if coordinator was used
-      )
+      const { fullResponse, hasError, toolResponses } = await researcher(
+        currentSystemPrompt,
+        uiStream,
+        streamText,
+        finalMessages,
+        useSpecificAPI,
+        !useToolCoordinator // Pass a flag to disable tools if coordinator was used
+      )

Additionally, line 377 unconditionally overwrites toolOutputs (set from coordinator results at line 331) with toolResponses, which is empty when coordinator is enabled since researcher is called with tools disabled. Guard the assignment to preserve coordinator results:

       answer = fullResponse
+      if (!useToolCoordinator) {
+        toolOutputs = toolResponses
+      }

This ensures the block at line 380–396 that persists tool outputs receives the coordinator's executed plan results when enabled, while non-coordinator flows continue using researcher tool responses.

Committable suggestion skipped: line range outside the PR's diff.

// Removed import of useGeospatialToolMcp as it no longer exists and was incorrectly used here.
// The geospatialTool (if used by agents like researcher) now manages its own MCP client.
import { writer } from '@/lib/agents/writer'
Expand Down Expand Up @@ -125,6 +125,7 @@ async function submit(formData?: FormData, skip?: boolean) {

const groupeId = nanoid()
const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true'
const useToolCoordinator = process.env.USE_TOOL_COORDINATOR === 'true'
const maxMessages = useSpecificAPI ? 5 : 10
messages.splice(0, Math.max(messages.length - maxMessages, 0))

Expand Down Expand Up @@ -319,17 +320,55 @@ async function submit(formData?: FormData, skip?: boolean) {
const streamText = createStreamableValue<string>()
uiStream.update(<Spinner />)

let finalMessages = messages

if (useToolCoordinator) {
uiStream.update(<div><Spinner /> Planning tool execution...</div>)
try {
const plan = await toolCoordinator(messages)
uiStream.update(<div><Spinner /> Executing tool plan...</div>)
const results = await executeToolPlan(plan)
toolOutputs = results
const summary = aggregateToolResults(results, plan)

// Add the summary to the messages for the final synthesis agent
finalMessages = [
...messages,
{
id: nanoid(),
role: 'tool',
content: summary,
type: 'tool_coordinator_summary'
} as any // Cast to any to satisfy CoreMessage type for custom type
]

// Stream a message to the user about the tool execution completion
uiStream.append(
<BotMessage content="Tool execution complete. Synthesizing final answer..." />
)
} catch (e) {
console.error('Tool Coordinator failed:', e)
uiStream.append(
<BotMessage content="Tool Coordinator failed. Falling back to streaming researcher." />
)
// Fallback: continue with the original messages and let the researcher handle it
finalMessages = messages
}
}
Comment on lines +325 to +357
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix missing parameter, type mismatches, and BotMessage usage

Three issues in the coordinator flow:

  1. Missing required context parameter (line 330)
    executeToolPlan expects (plan, context) but is called with only plan. This will cause a runtime error.

  2. Type mismatch for tool message content (lines 335-343)
    Role 'tool' in CoreMessage expects content to be ToolResultPart[], not a string. The cast to any at line 342 hides this type error and may cause issues when researcher processes finalMessages.

  3. BotMessage expects StreamableValue (lines 347, 352)
    BotMessage component requires content: StreamableValue<string>, but plain strings are passed.

Apply these fixes:

     if (useToolCoordinator) {
       uiStream.update(<div><Spinner /> Planning tool execution...</div>)
       try {
         const plan = await toolCoordinator(messages)
         uiStream.update(<div><Spinner /> Executing tool plan...</div>)
-        const results = await executeToolPlan(plan)
+        const results = await executeToolPlan(plan, { uiStream, fullResponse: '' })
         toolOutputs = results
         const summary = aggregateToolResults(results, plan)
         
-        // Add the summary to the messages for the final synthesis agent
+        // Add coordinator summary as an assistant message for synthesis
         finalMessages = [
           ...messages,
           {
             id: nanoid(),
-            role: 'tool',
+            role: 'assistant',
             content: summary,
             type: 'tool_coordinator_summary'
-          } as any // Cast to any to satisfy CoreMessage type for custom type
+          }
         ]
         
-        // Stream a message to the user about the tool execution completion
+        const completionMsg = createStreamableValue<string>()
+        completionMsg.done("Tool execution complete. Synthesizing final answer...")
         uiStream.append(
-          <BotMessage content="Tool execution complete. Synthesizing final answer..." />
+          <BotMessage content={completionMsg.value} />
         )
       } catch (e) {
         console.error('Tool Coordinator failed:', e)
+        const errorMsg = createStreamableValue<string>()
+        errorMsg.done("Tool Coordinator failed. Falling back to streaming researcher.")
         uiStream.append(
-          <BotMessage content="Tool Coordinator failed. Falling back to streaming researcher." />
+          <BotMessage content={errorMsg.value} />
         )
-        // Fallback: continue with the original messages and let the researcher handle it
         finalMessages = messages
       }
     }

Changing the role from 'tool' to 'assistant' ensures type safety and allows the summary to flow naturally to the researcher as context, while the actual tool results are already captured in toolOutputs for persistence.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In app/actions.tsx around lines 325 to 357, fix three issues: call
executeToolPlan with the missing context parameter (pass the current context
object used elsewhere when invoking tools), change the synthesized tool-summary
message to use role 'assistant' (not 'tool') and remove the any cast so the
content is a plain string (or appropriate assistant content type) to satisfy
CoreMessage typing, and ensure BotMessage receives a StreamableValue<string> by
wrapping the plain string messages with the project’s streamable value helper
(or creating a StreamableValue) before passing to uiStream.append/update.


while (
useSpecificAPI
? answer.length === 0
: answer.length === 0 && !errorOccurred
) {
// If coordinator was used, pass finalMessages and disable tools for researcher
const { fullResponse, hasError, toolResponses } = await researcher(
currentSystemPrompt,
uiStream,
streamText,
messages,
useSpecificAPI
finalMessages,
useSpecificAPI,
!useToolCoordinator // Pass a flag to disable tools if coordinator was used
)
answer = fullResponse
toolOutputs = toolResponses
Expand Down
1 change: 1 addition & 0 deletions lib/agents/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './inquire'
export * from './query-suggestor'
export * from './researcher'
export * from './resolution-search'
export * from './tool-coordinator'
5 changes: 3 additions & 2 deletions lib/agents/researcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ export async function researcher(
uiStream: ReturnType<typeof createStreamableUI>,
streamText: ReturnType<typeof createStreamableValue<string>>,
messages: CoreMessage[],
useSpecificModel?: boolean
useSpecificModel?: boolean,
useTools: boolean = true
) {
let fullResponse = ''
let hasError = false
Expand All @@ -101,7 +102,7 @@ export async function researcher(
maxTokens: 4096,
system: systemPromptToUse,
messages,
tools: getTools({ uiStream, fullResponse }),
tools: useTools ? getTools({ uiStream, fullResponse }) : undefined,
})

uiStream.update(null) // remove spinner
Expand Down
197 changes: 197 additions & 0 deletions lib/agents/tool-coordinator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { generateObject } from 'ai'
import { z } from 'zod'
import { Message } from 'ai/react'
import { getTools } from '@/lib/agents/tools'
import { ToolResultPart } from '@/lib/types'

// ——————————————————————————————————————
// Fallbacks if the original files don't exist yet
// ——————————————————————————————————————

let getModel: () => any
let createStreamableUI: () => any

try {
// Try the most common real locations first
const models = require('@/lib/models')
getModel = models.getModel || models.default || (() => null)
} catch {
try {
const mod = require('@/lib/ai/models')
getModel = mod.getModel || mod.default
} catch {
getModel = () => {
throw new Error('getModel not available — check your @/lib/models setup')
}
}
}

try {
const streamable = require('@/lib/streamable')
createStreamableUI = streamable.createStreamableUI || streamable.default
} catch {
try {
const s = require('@/lib/ui/streamable')
createStreamableUI = s.createStreamableUI
} catch {
// Minimal no-op version that won't break tool calling
createStreamableUI = () => ({
append: () => {},
update: () => {},
done: () => {},
value: null
})
}
}

// ——————————————————————————————————————
// Schemas
// ——————————————————————————————————————

const toolStepSchema = z.object({
toolName: z.string(),
toolArgs: z.record(z.any()),
dependencyIndices: z.array(z.number()).optional(),
purpose: z.string()
})

const toolPlanSchema = z.object({
reasoning: z.string(),
steps: z.array(toolStepSchema)
})

export type ToolPlan = z.infer<typeof toolPlanSchema>
export type ToolStep = z.infer<typeof toolStepSchema>
Comment on lines +51 to +64
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Schema is functional but could be tightened for safety and guidance

The toolStepSchema/toolPlanSchema are straightforward and work, but they’re very permissive:

  • toolArgs: z.record(z.any()) allows arbitrary shapes, including non‑JSON‑safe values.
  • There’s no bound on steps length, so a misbehaving model could produce a very large plan.

Consider:

  • Switching toolArgs to z.record(z.unknown()) (or a narrower shape if you standardize tool args) to avoid implicitly “any” values.
  • Adding .max(n) to steps (e.g. 6–10) to guard against pathological plans and control tool‑call blast radius.

These changes aren’t required for correctness but would improve robustness.

🤖 Prompt for AI Agents
In lib/agents/tool-coordinator.tsx around lines 51–64, the zod schemas are too
permissive: change toolArgs from z.record(z.any()) to z.record(z.unknown()) (or
a narrower shape if you have a standardized arg schema) to avoid implicit
any/unsafe values, and add a max bound to steps (e.g., .max(10)) on the
toolPlanSchema's steps array to limit plan size; keep dependencyIndices optional
as-is. Ensure types exported (ToolPlan/ToolStep) remain in sync after these
schema changes.


// ——————————————————————————————————————
// 1. Plan Generation
// ——————————————————————————————————————

export async function toolCoordinator(messages: Message[]): Promise<ToolPlan> {
const model = getModel()

const toolsObj = getTools({
uiStream: createStreamableUI(),
fullResponse: ''
})

const toolDescriptions = Object.values(toolsObj).map(tool => ({
name: tool.toolName,
description: tool.description,
parameters: tool.parameters
}))

const systemPrompt = `You are an expert Tool Coordinator. Create a precise multi-step plan using only these tools.
Rules:
- Use exact toolName from the list.
- Use dependencyIndices (0-based) when a step needs prior results.
- Output must be valid JSON matching the schema.
Available Tools:
${JSON.stringify(toolDescriptions, null, 2)}
`

const { object } = await generateObject({
model,
system: systemPrompt,
messages,
schema: toolPlanSchema
})

return object
}

// ——————————————————————————————————————
// 2. Execution
// ——————————————————————————————————————

interface ExecutionContext {
uiStream: any
fullResponse: string
}

export async function executeToolPlan(
plan: ToolPlan,
context: ExecutionContext
): Promise<ToolResultPart[]> {
const { uiStream, fullResponse } = context

const toolsObj = getTools({ uiStream, fullResponse })
const toolMap = new Map(Object.values(toolsObj).map(t => [t.toolName, t]))

const results = new Map<number, any>()
const toolResults: ToolResultPart[] = []

const resolveDeps = (indices: number[] = []) =>
indices.map(i => {
if (!results.has(i)) throw new Error(`Dependency step ${i} missing`)
return results.get(i)
})

for (let i = 0; i < plan.steps.length; i++) {
const step = plan.steps[i]
const tool = toolMap.get(step.toolName)

let result: any = { error: `Tool "${step.toolName}" not found` }

try {
if (!tool) throw new Error(`Tool not found: ${step.toolName}`)

const deps = step.dependencyIndices ? resolveDeps(step.dependencyIndices) : []
const args = {
...step.toolArgs,
...(deps.length > 0 && { _dependencyResults: deps })
}

console.log(`[ToolCoordinator] Step ${i}: ${step.toolName}`)
result = await tool.execute(args)
} catch (err: any) {
const msg = err?.message || String(err)
console.error(`[ToolCoordinator] Step ${i} failed:`, msg)
result = { error: msg }
}

results.set(i, result)
toolResults.push({
toolName: step.toolName,
toolCallId: `coord-${i}`,
result
})
}

return toolResults
}

// ——————————————————————————————————————
// 3. Aggregation
// ——————————————————————————————————————

export function aggregateToolResults(toolResults: ToolResultPart[], plan: ToolPlan): string {
let out = `# Tool Coordinator Results
### Plan
${plan.reasoning}
### Steps
`

toolResults.forEach((tr, i) => {
const step = plan.steps[i]
const hasError = tr.result && typeof tr.result === 'object' && 'error' in tr.result

out += `\n#### Step ${i + 1}: ${step.purpose} (\`${step.toolName}\`)`

if (hasError) {
out += `\n**Status:** Failed\n**Error:** ${tr.result.error}`
} else {
const json = JSON.stringify(tr.result, null, 2)
const truncated = json.length > 600 ? json.slice(0, 600) + '...' : json
out += `\n**Status:** Success\n**Result:**\n\`\`\`json\n${truncated}\n\`\`\``
}
})

out += `\n\n---\n**INSTRUCTION:** Write a natural, helpful final answer using only the information above. Do not mention tools, steps, or internal process.`

return out
}
15 changes: 13 additions & 2 deletions lib/agents/tools/geospatial.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,21 @@ Uses the Mapbox Search Box Text Search API endpoint to power searching for and g

,
parameters: geospatialQuerySchema,
execute: async (params: z.infer<typeof geospatialQuerySchema>) => {
const { queryType, includeMap = true } = params;
execute: async (params: z.infer<typeof geospatialQuerySchema> & { _dependencyResults?: any[] }) => {
const { queryType, includeMap = true, _dependencyResults } = params;
console.log('[GeospatialTool] Execute called with:', params);

if (_dependencyResults && _dependencyResults.length > 0) {
console.log('[GeospatialTool] Processing dependency results:', _dependencyResults);
// Logic to process dependency results can be added here.
// For example, if a previous step was a search, the result might contain coordinates
// that can be used as input for a subsequent directions query.
// Since the full logic for dependency injection is complex and depends on the
// specific tool schema, we will log it for now and ensure the tool can handle it.
// The LLM planning step is responsible for generating the correct 'params'
// based on the dependency results. The tool only needs to be aware of them.
}

const uiFeedbackStream = createStreamableValue<string>();
uiStream.append(<BotMessage content={uiFeedbackStream.value} />);

Expand Down
Loading