Use proper ag-ui tool calls in ag-ui-example. Fuck copilotkit
This commit is contained in:
parent
72a39d07a2
commit
9c766846c0
10 changed files with 395 additions and 300 deletions
|
|
@ -12,8 +12,10 @@
|
||||||
- New `human_decide` graph node emits AG-UI tool calls (`TOOL_CALL_START/ARGS/END`) for frontend integration
|
- New `human_decide` graph node emits AG-UI tool calls (`TOOL_CALL_START/ARGS/END`) for frontend integration
|
||||||
- New `emit_tool_call_start()`, `emit_tool_call_args()`, `emit_tool_call_end()` AG-UI event helpers
|
- New `emit_tool_call_start()`, `emit_tool_call_args()`, `emit_tool_call_end()` AG-UI event helpers
|
||||||
- New `AGUIEmitter.emit()` method for direct event emission
|
- New `AGUIEmitter.emit()` method for direct event emission
|
||||||
- **AG-UI Research Example**: Updated with interactive decision UI
|
- **AG-UI Research Example**: Human-in-the-loop research with client-side tool calling
|
||||||
- Decision panel with question editing (add/remove) at each decision point
|
- Frontend handles `human_decision` tool calls via AG-UI `TOOL_CALL_*` events
|
||||||
|
- Tool results sent directly to backend `/v1/research/stream` endpoint
|
||||||
|
- Backend queues decisions and continues the research graph
|
||||||
- **HotpotQA Evaluation**: Added HotpotQA dataset adapter for multi-hop QA benchmarks
|
- **HotpotQA Evaluation**: Added HotpotQA dataset adapter for multi-hop QA benchmarks
|
||||||
- Extracts unique documents from validation set context paragraphs
|
- Extracts unique documents from validation set context paragraphs
|
||||||
- Uses MAP for retrieval evaluation (multiple supporting documents per question)
|
- Uses MAP for retrieval evaluation (multiple supporting documents per question)
|
||||||
|
|
|
||||||
|
|
@ -219,8 +219,9 @@ In interactive mode, you can:
|
||||||
- Execute searches and review collected answers
|
- Execute searches and review collected answers
|
||||||
- Continue researching or synthesize when ready
|
- Continue researching or synthesize when ready
|
||||||
|
|
||||||
For a web-based interactive experience with visual decision UI, see the [AG-UI Research Example](https://github.com/ggozad/haiku.rag/tree/main/examples/ag-ui-research). The web interface provides:
|
For a web-based interactive experience, see the [AG-UI Research Example](https://github.com/ggozad/haiku.rag/tree/main/examples/ag-ui-research). The example demonstrates AG-UI client-side tool calling:
|
||||||
|
|
||||||
- Question editing panel to add/remove sub-questions at decision points
|
- Frontend handles `human_decision` tool calls via AG-UI `TOOL_CALL_*` events
|
||||||
- Search and Generate Report buttons for controlling research flow
|
- Decision UI rendered inline in the chat at each decision point
|
||||||
- Live state display showing answers, confidence, and progress
|
- Question editing (add/remove) and action buttons (Search, Generate Report)
|
||||||
|
- Tool results sent directly to the backend endpoint which queues decisions and continues the graph
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import logfire
|
||||||
from pydantic_ai import Agent, RunContext
|
from pydantic_ai import Agent, RunContext
|
||||||
|
|
||||||
from haiku.rag.config import AppConfig, Config
|
from haiku.rag.config import AppConfig, Config
|
||||||
from haiku.rag.graph.common import get_model
|
from haiku.rag.utils import get_model
|
||||||
|
|
||||||
from .context import load_message_history, save_message_history
|
from .context import load_message_history, save_message_history
|
||||||
from .models import A2AConfig, AgentDependencies, SearchResult
|
from .models import A2AConfig, AgentDependencies, SearchResult
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ Research assistant powered by [haiku.rag](https://ggozad.github.io/haiku.rag/),
|
||||||
|
|
||||||
### Agent + Graph Pattern
|
### Agent + Graph Pattern
|
||||||
|
|
||||||
This example demonstrates the **agent+graph** architecture pattern:
|
This example demonstrates the **agent+graph** architecture with AG-UI client-side tool calls:
|
||||||
|
|
||||||
1. **Conversational Agent** (`agent.py`):
|
1. **Conversational Agent** (`agent.py`):
|
||||||
- Pydantic AI agent handles user conversations
|
- Pydantic AI agent handles user conversations
|
||||||
|
|
@ -102,13 +102,14 @@ This example demonstrates the **agent+graph** architecture pattern:
|
||||||
|
|
||||||
2. **Interactive Research Graph** (haiku.rag):
|
2. **Interactive Research Graph** (haiku.rag):
|
||||||
- Multi-step research workflow invoked by the agent's tool
|
- Multi-step research workflow invoked by the agent's tool
|
||||||
- Pauses at decision points waiting for human input via async queue
|
- At decision points, emits AG-UI `TOOL_CALL_START/ARGS/END` events for `human_decision`
|
||||||
- Emits AG-UI events for real-time progress tracking
|
- Waits for tool result via async queue before continuing
|
||||||
|
|
||||||
3. **Decision Endpoint** (`main.py`):
|
3. **Client-Side Tool Handling** (AG-UI pattern):
|
||||||
- `/v1/research/decide` receives human decisions from frontend
|
- Frontend listens for `human_decision` tool calls via AG-UI events
|
||||||
- Forwards decisions to the waiting graph via `HumanDecision` queue
|
- Renders decision UI inline in chat when tool call is received
|
||||||
- Supports actions: `search`, `synthesize`, `modify_questions`
|
- User decision sent directly to backend `/v1/research/stream` endpoint
|
||||||
|
- Backend extracts tool result from messages and routes to waiting graph via async queue
|
||||||
|
|
||||||
4. **Shared Event Stream**:
|
4. **Shared Event Stream**:
|
||||||
- `AGUIEmitter` is shared between agent and graph
|
- `AGUIEmitter` is shared between agent and graph
|
||||||
|
|
@ -120,14 +121,14 @@ This example demonstrates the **agent+graph** architecture pattern:
|
||||||
- **Backend** (Python):
|
- **Backend** (Python):
|
||||||
- Uses published `ghcr.io/ggozad/haiku.rag:latest` Docker image as base
|
- Uses published `ghcr.io/ggozad/haiku.rag:latest` Docker image as base
|
||||||
- `agent.py`: Pydantic AI agent with `run_research` tool, manages `ActiveResearch` registry
|
- `agent.py`: Pydantic AI agent with `run_research` tool, manages `ActiveResearch` registry
|
||||||
- `main.py`: Custom AG-UI streaming endpoint, decision endpoint for human input
|
- `main.py`: Custom AG-UI streaming endpoint, extracts tool results from messages
|
||||||
- Real-time event forwarding from emitter to SSE stream
|
- Real-time event forwarding from emitter to SSE stream
|
||||||
|
|
||||||
- **Frontend** (Next.js/React):
|
- **Frontend** (Next.js/React):
|
||||||
- CopilotKit for AG-UI protocol integration
|
- AG-UI protocol integration for real-time streaming
|
||||||
|
- Handles `human_decision` tool calls with inline decision UI
|
||||||
- Split-pane UI: chat on left, live research state on right
|
- Split-pane UI: chat on left, live research state on right
|
||||||
- Decision UI: question editor with add/remove, search and generate report buttons
|
- Tool results sent directly to backend endpoint
|
||||||
- Real-time state synchronization via Server-Sent Events (SSE)
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ class AgentDeps:
|
||||||
agui_emitter: "AGUIEmitter[ResearchState, ResearchReport] | None" = None
|
agui_emitter: "AGUIEmitter[ResearchState, ResearchReport] | None" = None
|
||||||
search_filter: str | None = None
|
search_filter: str | None = None
|
||||||
thread_id: str | None = None
|
thread_id: str | None = None
|
||||||
|
research_result: "ResearchReport | None" = None
|
||||||
|
|
||||||
|
|
||||||
model = get_model(Config.research.model, Config)
|
model = get_model(Config.research.model, Config)
|
||||||
|
|
@ -119,6 +120,8 @@ async def run_research(ctx: RunContext[AgentDeps], question: str) -> str:
|
||||||
|
|
||||||
if ctx.deps.agui_emitter:
|
if ctx.deps.agui_emitter:
|
||||||
ctx.deps.agui_emitter.log("Research complete!")
|
ctx.deps.agui_emitter.log("Research complete!")
|
||||||
|
# Store result for main.py to emit RUN_FINISHED after agent completes
|
||||||
|
ctx.deps.research_result = result
|
||||||
|
|
||||||
return f"""Research completed successfully!
|
return f"""Research completed successfully!
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,11 +63,54 @@ def get_client(effective_db_path: Path) -> HaikuRAG:
|
||||||
return _client_cache[path_key]
|
return _client_cache[path_key]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_tool_result(messages: list[dict]) -> dict | None:
|
||||||
|
"""Extract human_decision tool result from messages if present."""
|
||||||
|
for msg in reversed(messages):
|
||||||
|
# Check for tool result message (CopilotKit sends role="tool")
|
||||||
|
if msg.get("role") == "tool":
|
||||||
|
content = msg.get("content")
|
||||||
|
# Content may be a string (JSON) or dict
|
||||||
|
if isinstance(content, str):
|
||||||
|
try:
|
||||||
|
content = json.loads(content)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(content, dict) and "action" in content:
|
||||||
|
return content
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def stream_research_agent(request: Request) -> StreamingResponse:
|
async def stream_research_agent(request: Request) -> StreamingResponse:
|
||||||
"""Agent streaming endpoint with research graph integration."""
|
"""Agent streaming endpoint with research graph integration."""
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
logger.info(f"Received request body keys: {list(body.keys())}")
|
||||||
|
if "tools" in body:
|
||||||
|
logger.info(f"Frontend tools received: {body['tools']}")
|
||||||
input_data = RunAgentInput(**body)
|
input_data = RunAgentInput(**body)
|
||||||
|
|
||||||
|
thread_id = input_data.thread_id
|
||||||
|
active_research = _active_research.get(thread_id) if thread_id else None
|
||||||
|
|
||||||
|
# Check if this is a tool result for active research
|
||||||
|
if active_research and input_data.messages:
|
||||||
|
tool_result = extract_tool_result(input_data.messages)
|
||||||
|
if tool_result:
|
||||||
|
logger.info(f"Received tool result: {tool_result}")
|
||||||
|
action = tool_result.get("action", "search")
|
||||||
|
questions = tool_result.get("questions")
|
||||||
|
|
||||||
|
decision = HumanDecision(
|
||||||
|
action=action,
|
||||||
|
questions=questions,
|
||||||
|
)
|
||||||
|
await active_research.queue.put(decision)
|
||||||
|
|
||||||
|
# Return acknowledgment - the original stream will continue
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([format_sse_event({"type": "TOOL_RESULT_RECEIVED"})]),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
)
|
||||||
|
|
||||||
user_message = ""
|
user_message = ""
|
||||||
if input_data.messages:
|
if input_data.messages:
|
||||||
user_message = input_data.messages[-1].get("content", "")
|
user_message = input_data.messages[-1].get("content", "")
|
||||||
|
|
@ -100,8 +143,6 @@ async def stream_research_agent(request: Request) -> StreamingResponse:
|
||||||
ids_str = ", ".join(f"'{id}'" for id in document_ids)
|
ids_str = ", ".join(f"'{id}'" for id in document_ids)
|
||||||
search_filter = f"id IN ({ids_str})"
|
search_filter = f"id IN ({ids_str})"
|
||||||
|
|
||||||
thread_id = input_data.thread_id
|
|
||||||
|
|
||||||
# Create agent dependencies with shared emitter
|
# Create agent dependencies with shared emitter
|
||||||
agent_deps = AgentDeps(
|
agent_deps = AgentDeps(
|
||||||
client=client,
|
client=client,
|
||||||
|
|
@ -121,9 +162,12 @@ async def stream_research_agent(request: Request) -> StreamingResponse:
|
||||||
# Forward emitter events to stream
|
# Forward emitter events to stream
|
||||||
async def forward_events():
|
async def forward_events():
|
||||||
async for event in emitter:
|
async for event in emitter:
|
||||||
# Log events for debugging
|
|
||||||
logger.info(f"AG-UI Event: {event}")
|
|
||||||
event_type = event.get("type")
|
event_type = event.get("type")
|
||||||
|
logger.info(f"AG-UI event: {event_type}")
|
||||||
|
|
||||||
|
# Log tool call events for debugging
|
||||||
|
if event_type and event_type.startswith("TOOL_CALL"):
|
||||||
|
logger.info(f"Tool call event: {event}")
|
||||||
|
|
||||||
# Convert ACTIVITY_SNAPSHOT to STATE_DELTA for CopilotKit
|
# Convert ACTIVITY_SNAPSHOT to STATE_DELTA for CopilotKit
|
||||||
# As CopilotKit does not handle ACTIVITY_SNAPSHOT events
|
# As CopilotKit does not handle ACTIVITY_SNAPSHOT events
|
||||||
|
|
@ -133,7 +177,6 @@ async def stream_research_agent(request: Request) -> StreamingResponse:
|
||||||
message = content.get("message", "")
|
message = content.get("message", "")
|
||||||
|
|
||||||
# Emit STATE_DELTA to patch activity info into state
|
# Emit STATE_DELTA to patch activity info into state
|
||||||
# Use "add" op which creates or replaces the value
|
|
||||||
delta_event = {
|
delta_event = {
|
||||||
"type": "STATE_DELTA",
|
"type": "STATE_DELTA",
|
||||||
"delta": [
|
"delta": [
|
||||||
|
|
@ -152,22 +195,6 @@ async def stream_research_agent(request: Request) -> StreamingResponse:
|
||||||
await send_stream.send(format_sse_event(delta_event))
|
await send_stream.send(format_sse_event(delta_event))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# When human_decision tool starts, set awaiting_decision flag
|
|
||||||
if event_type == "TOOL_CALL_START":
|
|
||||||
tool_name = event.get("toolCallName")
|
|
||||||
if tool_name == "human_decision":
|
|
||||||
delta_event = {
|
|
||||||
"type": "STATE_DELTA",
|
|
||||||
"delta": [
|
|
||||||
{
|
|
||||||
"op": "add",
|
|
||||||
"path": "/awaiting_decision",
|
|
||||||
"value": True,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
await send_stream.send(format_sse_event(delta_event))
|
|
||||||
|
|
||||||
# Sync state to ActiveResearch when human_decision tool call
|
# Sync state to ActiveResearch when human_decision tool call
|
||||||
if event_type == "TOOL_CALL_ARGS" and thread_id:
|
if event_type == "TOOL_CALL_ARGS" and thread_id:
|
||||||
delta = event.get("delta", "{}")
|
delta = event.get("delta", "{}")
|
||||||
|
|
@ -191,6 +218,9 @@ async def stream_research_agent(request: Request) -> StreamingResponse:
|
||||||
|
|
||||||
result = await agent.run(user_message, deps=agent_deps)
|
result = await agent.run(user_message, deps=agent_deps)
|
||||||
emitter.log(result.output)
|
emitter.log(result.output)
|
||||||
|
# Emit RUN_FINISHED with research result if available
|
||||||
|
if agent_deps.research_result:
|
||||||
|
emitter.finish_run(agent_deps.research_result)
|
||||||
await emitter.close()
|
await emitter.close()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -283,36 +313,10 @@ async def visualize_chunk(request: Request) -> JSONResponse:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def research_decide(request: Request) -> JSONResponse:
|
|
||||||
"""Endpoint to receive human decisions for active research."""
|
|
||||||
body = await request.json()
|
|
||||||
action = body.get("action", "search")
|
|
||||||
questions = body.get("questions", [])
|
|
||||||
|
|
||||||
# Get first active research (single-user example)
|
|
||||||
active = next(iter(_active_research.values()), None)
|
|
||||||
if not active:
|
|
||||||
return JSONResponse({"error": "No active research found"}, status_code=404)
|
|
||||||
|
|
||||||
# When "search" action is sent with questions, use "modify_questions" to update them
|
|
||||||
effective_action = (
|
|
||||||
"modify_questions" if action == "search" and questions else action
|
|
||||||
)
|
|
||||||
|
|
||||||
decision = HumanDecision(
|
|
||||||
action=effective_action,
|
|
||||||
questions=questions or None,
|
|
||||||
)
|
|
||||||
await active.queue.put(decision)
|
|
||||||
|
|
||||||
return JSONResponse({"status": "ok", "action": effective_action})
|
|
||||||
|
|
||||||
|
|
||||||
# Create Starlette app
|
# Create Starlette app
|
||||||
app = Starlette(
|
app = Starlette(
|
||||||
routes=[
|
routes=[
|
||||||
Route("/v1/research/stream", stream_research_agent, methods=["POST"]),
|
Route("/v1/research/stream", stream_research_agent, methods=["POST"]),
|
||||||
Route("/v1/research/decide", research_decide, methods=["POST"]),
|
|
||||||
Route("/api/documents", list_documents, methods=["GET"]),
|
Route("/api/documents", list_documents, methods=["GET"]),
|
||||||
Route("/api/visualize/{chunk_id}", visualize_chunk, methods=["GET"]),
|
Route("/api/visualize/{chunk_id}", visualize_chunk, methods=["GET"]),
|
||||||
Route("/health", health_check, methods=["GET"]),
|
Route("/health", health_check, methods=["GET"]),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm ci
|
RUN npm install --legacy-peer-deps
|
||||||
COPY . .
|
COPY . .
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,282 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CopilotKit, useCoAgent } from "@copilotkit/react-core";
|
import {
|
||||||
|
CopilotKit,
|
||||||
|
useCoAgent,
|
||||||
|
useCopilotAction,
|
||||||
|
useCopilotContext,
|
||||||
|
} from "@copilotkit/react-core";
|
||||||
import { CopilotChat } from "@copilotkit/react-ui";
|
import { CopilotChat } from "@copilotkit/react-ui";
|
||||||
import "@copilotkit/react-ui/styles.css";
|
import "@copilotkit/react-ui/styles.css";
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import DocumentSelector from "./DocumentSelector";
|
import DocumentSelector from "./DocumentSelector";
|
||||||
import StateDisplay from "./StateDisplay";
|
import StateDisplay from "./StateDisplay";
|
||||||
|
|
||||||
interface Citation {
|
|
||||||
document_id: string;
|
|
||||||
chunk_id: string;
|
|
||||||
document_uri: string;
|
|
||||||
document_title?: string;
|
|
||||||
page_numbers: number[];
|
|
||||||
headings?: string[];
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchAnswer {
|
interface SearchAnswer {
|
||||||
query: string;
|
query: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
cited_chunks: string[];
|
cited_chunks: string[];
|
||||||
citations: Citation[];
|
citations: {
|
||||||
|
document_id: string;
|
||||||
|
chunk_id: string;
|
||||||
|
document_uri: string;
|
||||||
|
document_title?: string;
|
||||||
|
page_numbers: number[];
|
||||||
|
headings?: string[];
|
||||||
|
content: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResearchContext {
|
interface ResearchState {
|
||||||
|
context: {
|
||||||
|
original_question: string;
|
||||||
|
sub_questions: string[];
|
||||||
|
qa_responses: SearchAnswer[];
|
||||||
|
};
|
||||||
|
iterations: number;
|
||||||
|
max_iterations: number;
|
||||||
|
confidence_threshold: number;
|
||||||
|
max_concurrency: number;
|
||||||
|
last_eval: {
|
||||||
|
new_questions: string[];
|
||||||
|
confidence_score: number;
|
||||||
|
is_sufficient: boolean;
|
||||||
|
reasoning: string;
|
||||||
|
} | null;
|
||||||
|
result?: {
|
||||||
|
title: string;
|
||||||
|
executive_summary: string;
|
||||||
|
main_findings: string[];
|
||||||
|
conclusions: string[];
|
||||||
|
limitations: string[];
|
||||||
|
recommendations: string[];
|
||||||
|
sources_summary: string;
|
||||||
|
};
|
||||||
|
current_activity?: string;
|
||||||
|
current_activity_message?: string;
|
||||||
|
documentFilter?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecisionArgs {
|
||||||
original_question: string;
|
original_question: string;
|
||||||
sub_questions: string[];
|
sub_questions: string[];
|
||||||
qa_responses: SearchAnswer[];
|
qa_responses: SearchAnswer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EvaluationResult {
|
type DecisionAction = "search" | "synthesize" | "modify_questions";
|
||||||
new_questions: string[];
|
|
||||||
confidence_score: number;
|
interface DecisionResult {
|
||||||
is_sufficient: boolean;
|
action: DecisionAction;
|
||||||
reasoning: string;
|
questions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResearchReport {
|
function DecisionUI({
|
||||||
title: string;
|
args,
|
||||||
executive_summary: string;
|
onResolve,
|
||||||
main_findings: string[];
|
}: {
|
||||||
conclusions: string[];
|
args: DecisionArgs;
|
||||||
limitations: string[];
|
onResolve: (result: DecisionResult) => void | Promise<void>;
|
||||||
recommendations: string[];
|
}) {
|
||||||
sources_summary: string;
|
const [editableQuestions, setEditableQuestions] = useState<string[]>(
|
||||||
|
args.sub_questions || [],
|
||||||
|
);
|
||||||
|
const [newQuestion, setNewQuestion] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const qaCount = args.qa_responses?.length || 0;
|
||||||
|
const hasQuestions = editableQuestions.length > 0;
|
||||||
|
const canSearch = hasQuestions && !submitting;
|
||||||
|
const canSynthesize = qaCount > 0 && !submitting;
|
||||||
|
|
||||||
|
const questionsModified =
|
||||||
|
editableQuestions.length !== args.sub_questions.length ||
|
||||||
|
editableQuestions.some((q, i) => q !== args.sub_questions[i]);
|
||||||
|
|
||||||
|
const handleSubmit = (action: DecisionAction, questions?: string[]) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
onResolve({ action, questions });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
handleSubmit(
|
||||||
|
questionsModified ? "modify_questions" : "search",
|
||||||
|
editableQuestions,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSynthesize = () => {
|
||||||
|
handleSubmit("synthesize");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveQuestion = (index: number) => {
|
||||||
|
if (submitting) return;
|
||||||
|
setEditableQuestions(editableQuestions.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddQuestion = () => {
|
||||||
|
if (submitting || !newQuestion.trim()) return;
|
||||||
|
setEditableQuestions([...editableQuestions, newQuestion.trim()]);
|
||||||
|
setNewQuestion("");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (submitting) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "1rem",
|
||||||
|
background: "#f0f9ff",
|
||||||
|
border: "2px solid #0ea5e9",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#0369a1",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Research Decision Point
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: "#64748b",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{qaCount} answers collected
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "0.75rem" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "#475569",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pending Questions ({editableQuestions.length}):
|
||||||
|
</div>
|
||||||
|
{editableQuestions.map((q, idx) => (
|
||||||
|
<div
|
||||||
|
key={`question-${idx}`}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.375rem 0.5rem",
|
||||||
|
background: "white",
|
||||||
|
borderRadius: "4px",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1 }}>{q}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveQuestion(idx)}
|
||||||
|
style={{
|
||||||
|
background: "#ef4444",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: "0.25rem 0.5rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newQuestion}
|
||||||
|
onChange={(e) => setNewQuestion(e.target.value)}
|
||||||
|
placeholder="Add a new question..."
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "0.5rem",
|
||||||
|
border: "1px solid #cbd5e1",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleAddQuestion();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddQuestion}
|
||||||
|
style={{
|
||||||
|
background: "#22c55e",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={!canSearch}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: canSearch ? "#0ea5e9" : "#94a3b8",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: "0.75rem",
|
||||||
|
cursor: canSearch ? "pointer" : "not-allowed",
|
||||||
|
fontWeight: "bold",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Search ({editableQuestions.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSynthesize}
|
||||||
|
disabled={!canSynthesize}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: canSynthesize ? "#8b5cf6" : "#94a3b8",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: "0.75rem",
|
||||||
|
cursor: canSynthesize ? "pointer" : "not-allowed",
|
||||||
|
fontWeight: "bold",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Generate Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResearchState {
|
const BACKEND_URL =
|
||||||
context: ResearchContext;
|
process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000";
|
||||||
iterations: number;
|
|
||||||
max_iterations: number;
|
|
||||||
confidence_threshold: number;
|
|
||||||
max_concurrency: number;
|
|
||||||
last_eval: EvaluationResult | null;
|
|
||||||
result?: ResearchReport;
|
|
||||||
current_activity?: string;
|
|
||||||
current_activity_message?: string;
|
|
||||||
documentFilter?: string[];
|
|
||||||
awaiting_decision?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AgentContent() {
|
function AgentContent() {
|
||||||
const { state, setState, running } = useCoAgent<ResearchState>({
|
const { state, setState, running } = useCoAgent<ResearchState>({
|
||||||
|
|
@ -77,61 +293,79 @@ function AgentContent() {
|
||||||
max_concurrency: 1,
|
max_concurrency: 1,
|
||||||
last_eval: null,
|
last_eval: null,
|
||||||
documentFilter: [],
|
documentFilter: [],
|
||||||
awaiting_decision: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [editableQuestions, setEditableQuestions] = useState<string[]>([]);
|
const { threadId } = useCopilotContext();
|
||||||
const [newQuestion, setNewQuestion] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
// Sync editable questions when state changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (state.awaiting_decision && state.context.sub_questions) {
|
|
||||||
setEditableQuestions([...state.context.sub_questions]);
|
|
||||||
}
|
|
||||||
}, [state.awaiting_decision, state.context.sub_questions]);
|
|
||||||
|
|
||||||
const handleDocumentFilterChange = (ids: string[]) => {
|
const handleDocumentFilterChange = (ids: string[]) => {
|
||||||
setState({ ...state, documentFilter: ids });
|
setState({ ...state, documentFilter: ids });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveQuestion = (index: number) => {
|
const sendToolResult = async (result: DecisionResult) => {
|
||||||
setEditableQuestions(editableQuestions.filter((_, i) => i !== index));
|
if (!threadId) {
|
||||||
};
|
console.error("No threadId available to send tool result");
|
||||||
|
return;
|
||||||
const handleAddQuestion = () => {
|
|
||||||
if (newQuestion.trim()) {
|
|
||||||
setEditableQuestions([...editableQuestions, newQuestion.trim()]);
|
|
||||||
setNewQuestion("");
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleDecision = async (action: "search" | "synthesize") => {
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(`${BACKEND_URL}/v1/research/stream`, {
|
||||||
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/research/decide`,
|
method: "POST",
|
||||||
{
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
body: JSON.stringify({
|
||||||
headers: { "Content-Type": "application/json" },
|
threadId,
|
||||||
body: JSON.stringify({
|
messages: [
|
||||||
thread_id: state.context.original_question, // Use as identifier
|
{
|
||||||
action,
|
id: crypto.randomUUID(),
|
||||||
questions: editableQuestions,
|
role: "tool",
|
||||||
}),
|
content: JSON.stringify(result),
|
||||||
}
|
},
|
||||||
);
|
],
|
||||||
if (response.ok) {
|
}),
|
||||||
setState({ ...state, awaiting_decision: false });
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Failed to send tool result:", response.status);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send decision:", error);
|
console.error("Error sending tool result:", error);
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useCopilotAction({
|
||||||
|
name: "human_decision",
|
||||||
|
description: "Pause for human decision on research direction",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "original_question",
|
||||||
|
type: "string",
|
||||||
|
description: "The original research question",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sub_questions",
|
||||||
|
type: "string[]",
|
||||||
|
description: "Pending sub-questions to search",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "qa_responses",
|
||||||
|
type: "object[]",
|
||||||
|
description: "Answers collected so far",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
renderAndWaitForResponse: ({ args, status }) => {
|
||||||
|
if (status === "complete") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DecisionUI
|
||||||
|
args={args as unknown as DecisionArgs}
|
||||||
|
onResolve={sendToolResult}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<style>{`
|
||||||
|
|
@ -148,7 +382,6 @@ function AgentContent() {
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<div style={{ display: "flex", height: "100vh" }}>
|
<div style={{ display: "flex", height: "100vh" }}>
|
||||||
{/* Chat on the left */}
|
|
||||||
<div className="chat-container">
|
<div className="chat-container">
|
||||||
<CopilotChat
|
<CopilotChat
|
||||||
labels={{
|
labels={{
|
||||||
|
|
@ -159,7 +392,6 @@ function AgentContent() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* State display on the right */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: "50%",
|
width: "50%",
|
||||||
|
|
@ -191,7 +423,6 @@ function AgentContent() {
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Document filter - hidden when research is running */}
|
|
||||||
{!running && (
|
{!running && (
|
||||||
<div style={{ marginBottom: "1rem" }}>
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
<DocumentSelector
|
<DocumentSelector
|
||||||
|
|
@ -201,153 +432,6 @@ function AgentContent() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Decision UI when awaiting human input - hidden when report exists, submitting, or not running */}
|
|
||||||
{state.awaiting_decision && !submitting && !state.result && running && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: "1rem",
|
|
||||||
background: "#f0f9ff",
|
|
||||||
border: "2px solid #0ea5e9",
|
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontWeight: "bold",
|
|
||||||
color: "#0369a1",
|
|
||||||
marginBottom: "0.75rem",
|
|
||||||
fontSize: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Research Decision Point
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: "0.85rem", color: "#64748b", marginBottom: "0.75rem" }}>
|
|
||||||
{state.context.qa_responses?.length || 0} answers collected | Iteration {state.iterations || 0}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Questions list */}
|
|
||||||
<div style={{ marginBottom: "0.75rem" }}>
|
|
||||||
<div style={{ fontSize: "0.8rem", color: "#475569", marginBottom: "0.5rem" }}>
|
|
||||||
Pending Questions ({editableQuestions.length}):
|
|
||||||
</div>
|
|
||||||
{editableQuestions.map((q, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.5rem",
|
|
||||||
padding: "0.375rem 0.5rem",
|
|
||||||
background: "white",
|
|
||||||
borderRadius: "4px",
|
|
||||||
marginBottom: "0.25rem",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ flex: 1 }}>{q}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRemoveQuestion(idx)}
|
|
||||||
disabled={submitting}
|
|
||||||
style={{
|
|
||||||
background: "#ef4444",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
padding: "0.25rem 0.5rem",
|
|
||||||
cursor: submitting ? "not-allowed" : "pointer",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add question input */}
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newQuestion}
|
|
||||||
onChange={(e) => setNewQuestion(e.target.value)}
|
|
||||||
placeholder="Add a new question..."
|
|
||||||
disabled={submitting}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: "0.5rem",
|
|
||||||
border: "1px solid #cbd5e1",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
handleAddQuestion();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAddQuestion}
|
|
||||||
disabled={submitting}
|
|
||||||
style={{
|
|
||||||
background: "#22c55e",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
padding: "0.5rem 1rem",
|
|
||||||
cursor: submitting ? "not-allowed" : "pointer",
|
|
||||||
fontSize: "0.85rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDecision("search")}
|
|
||||||
disabled={editableQuestions.length === 0 || submitting}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
background: editableQuestions.length === 0 || submitting ? "#94a3b8" : "#0ea5e9",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
padding: "0.75rem",
|
|
||||||
cursor: editableQuestions.length === 0 || submitting ? "not-allowed" : "pointer",
|
|
||||||
fontWeight: "bold",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{submitting ? "Submitting..." : `Search (${editableQuestions.length})`}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDecision("synthesize")}
|
|
||||||
disabled={submitting || (state.context.qa_responses?.length || 0) === 0}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
background: submitting || (state.context.qa_responses?.length || 0) === 0 ? "#94a3b8" : "#8b5cf6",
|
|
||||||
color: "white",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
padding: "0.75rem",
|
|
||||||
cursor: submitting || (state.context.qa_responses?.length || 0) === 0 ? "not-allowed" : "pointer",
|
|
||||||
fontWeight: "bold",
|
|
||||||
fontSize: "0.9rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Generate Report
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<StateDisplay state={state} />
|
<StateDisplay state={state} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ export default function StateDisplay({ state }: StateDisplayProps) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Question */}
|
{/* Question */}
|
||||||
{state.context.original_question && (
|
{state.context?.original_question && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "white",
|
background: "white",
|
||||||
|
|
@ -364,7 +364,7 @@ export default function StateDisplay({ state }: StateDisplayProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Answers */}
|
{/* Answers */}
|
||||||
{state.context.qa_responses.length > 0 && (
|
{state.context?.qa_responses && state.context.qa_responses.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "white",
|
background: "white",
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ag-ui/client": "^0.0.42",
|
"@ag-ui/client": "^0.0.42",
|
||||||
"@copilotkit/react-core": "^1.10.6",
|
"@copilotkit/react-core": "^1.50.0",
|
||||||
"@copilotkit/react-ui": "^1.10.6",
|
"@copilotkit/react-ui": "^1.50.0",
|
||||||
"@copilotkit/runtime": "^1.10.6",
|
"@copilotkit/runtime": "^1.50.0",
|
||||||
"next": "15.5.5",
|
"next": "15.5.5",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue