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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parse } from "best-effort-json-parser";
import { type FC, memo } from "react";
import { UnknownRenderer } from "@/components/valuecell/renderer";
import { COMPONENT_RENDERER_MAP } from "@/constants/agent";
Expand Down Expand Up @@ -44,6 +45,16 @@ const ChatItemArea: FC<ChatItemAreaProps> = ({ items }) => {
case "scheduled_task_controller":
return <RendererComponent content={item.payload.content} />;

case "reasoning": {
const parsed = parse(item.payload.content);
return (
<RendererComponent
content={parsed?.content ?? ""}
isComplete={parsed?.isComplete ?? false}
/>
);
}

case "report":
return (
<RendererComponent
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/valuecell/renderer/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { default as ChatConversationRenderer } from "./chat-conversation-renderer";
export { default as MarkdownRenderer } from "./markdown-renderer";
export { default as ReasoningRenderer } from "./reasoning-renderer";
export { default as ReportRenderer } from "./report-renderer";
export { default as ScheduledTaskControllerRenderer } from "./scheduled-task-controller-renderer";
export { default as ScheduledTaskRenderer } from "./scheduled-task-renderer";
Expand Down
69 changes: 69 additions & 0 deletions frontend/src/components/valuecell/renderer/reasoning-renderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Brain, ChevronDown } from "lucide-react";
import { type FC, memo, useState } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import type { ReasoningRendererProps } from "@/types/renderer";
import MarkdownRenderer from "./markdown-renderer";

const ReasoningRenderer: FC<ReasoningRendererProps> = ({
content,
isComplete,
}) => {
const [isOpen, setIsOpen] = useState(false);
const hasContent = content && content.trim().length > 0;

return (
<Collapsible
open={isOpen}
onOpenChange={setIsOpen}
className={cn("min-w-96 rounded-lg border-gradient p-3")}
data-active={isOpen}
>
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between",
hasContent && "cursor-pointer",
)}
disabled={!hasContent}
>
<div className="flex items-center gap-2 text-gray-950">
{isComplete ? (
<Brain className="size-5" />
) : (
<Spinner className="size-5" />
)}
<p className="text-base leading-5">
{isComplete ? "Thinking" : "Thinking..."}
</p>
</div>
{hasContent && (
<ChevronDown
className={cn(
"h-6 w-6 text-gray-950 transition-transform",
isOpen && "rotate-180",
)}
/>
)}
</CollapsibleTrigger>

{/* Collapsible Content */}
<CollapsibleContent>
<div className="pt-2">
{hasContent && (
<MarkdownRenderer
content={content}
className="text-gray-600 text-xs"
/>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
};

export default memo(ReasoningRenderer);
3 changes: 3 additions & 0 deletions frontend/src/constants/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import {
ChatConversationRenderer,
MarkdownRenderer,
ReasoningRenderer,
ReportRenderer,
ScheduledTaskControllerRenderer,
ScheduledTaskRenderer,
Expand All @@ -43,6 +44,7 @@ export const AGENT_MULTI_SECTION_COMPONENT_TYPE = ["report"] as const;
// agent component type
export const AGENT_COMPONENT_TYPE = [
"markdown",
"reasoning",
"tool_call",
"subagent_conversation",
"scheduled_task_controller",
Expand All @@ -59,6 +61,7 @@ export const COMPONENT_RENDERER_MAP: {
scheduled_task_result: ScheduledTaskRenderer,
scheduled_task_controller: ScheduledTaskControllerRenderer,
report: ReportRenderer,
reasoning: ReasoningRenderer,
markdown: MarkdownRenderer,
tool_call: ToolCallRenderer,
subagent_conversation: ChatConversationRenderer,
Expand Down
120 changes: 112 additions & 8 deletions frontend/src/lib/agent-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,50 @@ function hasContent(
return "payload" in item && "content" in item.payload;
}

// Mark a specific reasoning item as complete
function markReasoningComplete(task: TaskView, itemId: string): void {
const existingIndex = task.items.findIndex((item) => item.item_id === itemId);
if (existingIndex >= 0 && hasContent(task.items[existingIndex])) {
try {
const parsed = JSON.parse(task.items[existingIndex].payload.content);
task.items[existingIndex].payload.content = JSON.stringify({
...parsed,
isComplete: true,
});
} catch {
// If parsing fails, just mark as complete
task.items[existingIndex].payload.content = JSON.stringify({
content: task.items[existingIndex].payload.content,
isComplete: true,
});
}
}
}

// Mark all reasoning items in a task as complete
function markAllReasoningComplete(task: TaskView): void {
for (const item of task.items) {
if (item.component_type === "reasoning" && hasContent(item)) {
try {
const parsed = JSON.parse(item.payload.content);
if (!parsed.isComplete) {
item.payload.content = JSON.stringify({
...parsed,
isComplete: true,
});
}
} catch {
// Skip items that can't be parsed
}
}
}
}

// Helper function: add or update item in task
function addOrUpdateItem(
task: TaskView,
newItem: ChatItem,
event: "append" | "replace",
event: "append" | "replace" | "append-reasoning",
): void {
const existingIndex = task.items.findIndex(
(item) => item.item_id === newItem.item_id,
Expand All @@ -79,6 +118,23 @@ function addOrUpdateItem(
// Merge content for streaming events, replace for others
if (event === "append" && hasContent(existingItem) && hasContent(newItem)) {
existingItem.payload.content += newItem.payload.content;
} else if (
event === "append-reasoning" &&
hasContent(existingItem) &&
hasContent(newItem)
) {
// Special handling for reasoning: parse JSON, append content, re-serialize
try {
const existingParsed = JSON.parse(existingItem.payload.content);
const newParsed = JSON.parse(newItem.payload.content);
existingItem.payload.content = JSON.stringify({
content: (existingParsed.content ?? "") + (newParsed.content ?? ""),
isComplete: newParsed.isComplete ?? false,
});
} catch {
// Fallback to replace if parsing fails
task.items[existingIndex] = newItem;
}
} else {
task.items[existingIndex] = newItem;
}
Expand All @@ -88,7 +144,7 @@ function addOrUpdateItem(
function handleChatItemEvent(
draft: AgentConversationsStore,
data: ChatItem,
event: "append" | "replace" = "append",
event: "append" | "replace" | "append-reasoning" = "append",
) {
const { conversation, task } = ensurePath(draft, data);

Expand Down Expand Up @@ -144,14 +200,56 @@ function processSSEEvent(draft: AgentConversationsStore, sseData: SSEData) {
case "thread_started":
case "message_chunk":
case "message":
case "reasoning":
case "task_failed":
case "plan_failed":
case "plan_require_user_input":
// Other events are set as markdown type
handleChatItemEvent(draft, { component_type: "markdown", ...data });
break;

case "reasoning":
// Reasoning is streaming content that needs to be appended (like message_chunk)
handleChatItemEvent(
draft,
{
component_type: "reasoning",
...data,
payload: {
content: JSON.stringify({
content: data.payload.content,
isComplete: false,
}),
},
},
"append-reasoning",
);
break;

case "reasoning_started":
// Create initial reasoning item with empty content
handleChatItemEvent(
draft,
{
component_type: "reasoning",
...data,
payload: {
content: JSON.stringify({
content: "",
isComplete: false,
}),
},
},
"replace",
);
break;

case "reasoning_completed": {
// Mark reasoning as complete
const { task } = ensurePath(draft, data);
markReasoningComplete(task, data.item_id);
break;
}

case "tool_call_started":
case "tool_call_completed": {
handleChatItemEvent(
Expand All @@ -168,11 +266,6 @@ function processSSEEvent(draft: AgentConversationsStore, sseData: SSEData) {
break;
}

case "reasoning_started":
case "reasoning_completed":
ensurePath(draft, data);
break;

default:
break;
}
Expand Down Expand Up @@ -212,5 +305,16 @@ export function batchUpdateAgentConversationsStore(
for (const sseData of sseDataList) {
processSSEEvent(draft, sseData);
}

// Mark all reasoning items as complete after loading history
// since the stream has already finished
const conversation = draft[conversationId];
if (conversation) {
for (const thread of Object.values(conversation.threads)) {
for (const task of Object.values(thread.tasks)) {
markAllReasoningComplete(task);
}
}
}
});
}
4 changes: 4 additions & 0 deletions frontend/src/types/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export type BaseRendererProps = {
export type ReportRendererProps = BaseRendererProps & {
isActive?: boolean;
};
export type ReasoningRendererProps = BaseRendererProps & {
isComplete?: boolean;
};
export type ScheduledTaskRendererProps = BaseRendererProps;
export type ScheduledTaskControllerRendererProps = BaseRendererProps;
export type MarkdownRendererProps = BaseRendererProps;
Expand All @@ -24,6 +27,7 @@ export type RendererPropsMap = {
scheduled_task_result: ScheduledTaskRendererProps;
scheduled_task_controller: ScheduledTaskControllerRendererProps;
report: ReportRendererProps;
reasoning: ReasoningRendererProps;
markdown: MarkdownRendererProps;
tool_call: ToolCallRendererProps;
subagent_conversation: ChatConversationRendererProps;
Expand Down
59 changes: 41 additions & 18 deletions python/valuecell/core/coordinate/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,37 +296,60 @@ async def _handle_new_request(

# 1) Super Agent triage phase (pre-planning) - skip if target agent is specified
if user_input.target_agent_name == self.super_agent_service.name:
# Emit tool-call STARTED for super agent triage
# Emit reasoning_started before streaming reasoning content
sa_task_id = generate_task_id()
sa_tool_call_id = generate_uuid("toolcall")
sa_tool_name = "super_agent_triage"
sa_reasoning_item_id = generate_uuid("reasoning")
yield await self.event_service.emit(
self.event_service.factory.tool_call(
self.event_service.factory.reasoning(
conversation_id,
thread_id,
task_id=sa_task_id,
event=StreamResponseEvent.TOOL_CALL_STARTED,
tool_call_id=sa_tool_call_id,
tool_name=sa_tool_name,
)
)

super_outcome: SuperAgentOutcome = await self.super_agent_service.run(
user_input
event=StreamResponseEvent.REASONING_STARTED,
agent_name=self.super_agent_service.name,
item_id=sa_reasoning_item_id,
),
)

# Stream reasoning content and collect final outcome
super_outcome: SuperAgentOutcome | None = None
async for item in self.super_agent_service.run(user_input):
if isinstance(item, str):
# Yield reasoning chunk
yield await self.event_service.emit(
self.event_service.factory.reasoning(
conversation_id,
thread_id,
task_id=sa_task_id,
event=StreamResponseEvent.REASONING,
content=item,
agent_name=self.super_agent_service.name,
item_id=sa_reasoning_item_id,
),
)
else:
# SuperAgentOutcome received
super_outcome = item

# Emit reasoning_completed
yield await self.event_service.emit(
self.event_service.factory.tool_call(
self.event_service.factory.reasoning(
conversation_id,
thread_id,
task_id=sa_task_id,
event=StreamResponseEvent.TOOL_CALL_COMPLETED,
tool_call_id=sa_tool_call_id,
tool_name=sa_tool_name,
tool_result=f"Decision: {super_outcome.decision.value}",
)
event=StreamResponseEvent.REASONING_COMPLETED,
agent_name=self.super_agent_service.name,
item_id=sa_reasoning_item_id,
),
)

# Fallback if no outcome was received
if super_outcome is None:
super_outcome = SuperAgentOutcome(
decision=SuperAgentDecision.HANDOFF_TO_PLANNER,
enriched_query=user_input.query,
reason="No outcome received from SuperAgent",
)

if super_outcome.answer_content:
ans = self.event_service.factory.message_response_general(
StreamResponseEvent.MESSAGE_CHUNK,
Expand Down
Loading