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
Binary file added .github/assets/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
Chat with your data, build pipelines, and manage analytics assets—all on your local machine.

<p align="center">
<img src="docs/screenshot.png" alt="Pluto Duck Screenshot" width="80%" />
<img src=".github/assets/screenshot.png" alt="Pluto Duck Screenshot" width="80%" />
</p>

## 🌟 Product Vision
Expand Down
36 changes: 28 additions & 8 deletions backend/pluto_duck_backend/agent/core/deep/event_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def __init__(
self._run_id = run_id
self._conversation_id = conversation_id
self._experiment_profile = experiment_profile or prompt_layout
self._tool_stack: list[str] = []
self._tool_stack: list[dict[str, str]] = []
self._chunk_buffer: list[str] = []
self._chunk_tokens = 0
self._chunk_chars = 0
Expand Down Expand Up @@ -201,6 +201,19 @@ def _coerce_text(self, value: Any) -> str | None: # noqa: ANN401
return None
return text

def _resolve_tool_call_id(self, serialized: dict[str, Any], kwargs: dict[str, Any]) -> str:
candidates = (
kwargs.get("tool_call_id"),
kwargs.get("id"),
serialized.get("tool_call_id"),
serialized.get("call_id"),
)
for candidate in candidates:
text = self._coerce_text(candidate)
if text is not None:
return text
return f"tool-{uuid4()}"

def _join_text_fragments(self, fragments: list[str]) -> str | None:
normalized = [
text
Expand Down Expand Up @@ -496,26 +509,33 @@ async def on_tool_start(
**kwargs: Any,
) -> None: # noqa: ANN401
tool_name = serialized.get("name") or serialized.get("id") or "tool"
self._tool_stack.append(tool_name)
tool_call_id = self._resolve_tool_call_id(serialized, kwargs)
self._tool_stack.append({"name": tool_name, "tool_call_id": tool_call_id})
await self._emit(
AgentEvent(
type=EventType.TOOL,
subtype=EventSubType.START,
content={"tool": tool_name, "input": input_str},
metadata={"run_id": self._run_id},
content={"tool": tool_name, "input": input_str, "tool_call_id": tool_call_id},
metadata={"run_id": self._run_id, "tool_call_id": tool_call_id},
timestamp=self._ts(),
)
)

async def on_tool_end(self, output: Any, **kwargs: Any) -> None: # noqa: ANN401
# Pop the tool name from the stack to match with start event
tool_name = self._tool_stack.pop() if self._tool_stack else "tool"
# Keep the same tool_call_id between start/end so frontend correlation is stable.
if self._tool_stack:
tool_context = self._tool_stack.pop()
tool_name = tool_context["name"]
tool_call_id = tool_context["tool_call_id"]
Comment on lines +526 to +529

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Correlate tool end events by tool_call_id

on_tool_end always pops the most recent stack entry and ignores the current callback's tool_call_id while the stack is non-empty, so overlapping tool executions can be mismatched (e.g., start A, start B, end A will be emitted with B's id). In that case the frontend tool correlator (which keys by tool_call_id) will merge outputs into the wrong tool row, causing incorrect status/output pairing and actions like send-to-board to attach to the wrong call.

Useful? React with 👍 / 👎.

else:
tool_name = "tool"
tool_call_id = self._coerce_text(kwargs.get("tool_call_id")) or f"tool-{uuid4()}"
await self._emit(
AgentEvent(
type=EventType.TOOL,
subtype=EventSubType.END,
content={"tool": tool_name, "output": self._json_safe(output)},
metadata={"run_id": self._run_id},
content={"tool": tool_name, "output": self._json_safe(output), "tool_call_id": tool_call_id},
metadata={"run_id": self._run_id, "tool_call_id": tool_call_id},
timestamp=self._ts(),
)
)
19 changes: 15 additions & 4 deletions backend/pluto_duck_backend/app/services/chat/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -1059,11 +1059,22 @@ def get_conversation_events(self, conversation_id: str, limit: int = 200) -> Lis
conversation_run_id = self._as_non_empty_str(run_row[0] if run_row else None)
rows = con.execute(
"""
WITH latest_events AS (
SELECT id, type, subtype, payload, metadata, timestamp
FROM agent_events
WHERE conversation_id = ?
ORDER BY
COALESCE(TRY_CAST(json_extract_string(metadata, '$.display_order') AS BIGINT), 0) DESC,
timestamp DESC,
id DESC
LIMIT ?
)
SELECT id, type, subtype, payload, metadata, timestamp
FROM agent_events
WHERE conversation_id = ?
ORDER BY timestamp ASC, id ASC
LIMIT ?
FROM latest_events
ORDER BY
COALESCE(TRY_CAST(json_extract_string(metadata, '$.display_order') AS BIGINT), 0) ASC,
timestamp ASC,
id ASC
""",
[conversation_id, limit],
).fetchall()
Expand Down
34 changes: 34 additions & 0 deletions backend/tests/agent/test_orchestrator_display_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,40 @@ async def test_hitl_events_get_unique_display_order() -> None:
assert len(display_orders) >= 4


@pytest.mark.asyncio
async def test_tool_start_end_emit_stable_tool_call_id() -> None:
"""Tool start/end events must share tool_call_id for frontend correlation."""
collected: List[Dict[str, Any]] = []
callback_ref: list[PlutoDuckEventCallbackHandler | None] = [None]

emit = _build_emit(collected=collected, callback_ref=callback_ref)

handler = PlutoDuckEventCallbackHandler(
sink=EventSink(emit=emit),
run_id="run-tool-id",
display_order_start=1,
)
callback_ref[0] = handler

await handler.on_tool_start({"name": "save_analysis"}, '{"name":"a"}')
await handler.on_tool_end({"status": "success"})

tool_events = [event for event in collected if event["type"] == "tool"]
assert len(tool_events) == 2

start_event = tool_events[0]
end_event = tool_events[1]

start_tool_call_id = start_event["metadata"].get("tool_call_id")
end_tool_call_id = end_event["metadata"].get("tool_call_id")

assert isinstance(start_tool_call_id, str)
assert start_tool_call_id
assert start_event["content"]["tool_call_id"] == start_tool_call_id
assert end_tool_call_id == start_tool_call_id
assert end_event["content"]["tool_call_id"] == start_tool_call_id


@pytest.mark.asyncio
async def test_finally_events_get_display_order() -> None:
"""MESSAGE/FINAL and RUN/END events emitted via emit() get display_order assigned."""
Expand Down
54 changes: 54 additions & 0 deletions backend/tests/services/test_chat_repository_event_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,57 @@ def test_append_and_get_messages_preserve_display_order_contract(tmp_path) -> No
assert messages[0]["content"]["display_order"] == 41
assert messages[1]["display_order"] == 42
assert messages[1]["content"]["display_order"] == 42


def test_get_conversation_events_returns_latest_window(tmp_path) -> None:
warehouse = tmp_path / "warehouse.duckdb"
repo = ChatRepository(warehouse)

conversation_id = str(uuid4())
run_id = str(uuid4())
repo.create_conversation(conversation_id, "hello", {})
repo.set_active_run(conversation_id, run_id)

for idx in range(220):
repo.log_event(
conversation_id,
{
"type": "reasoning",
"subtype": "chunk",
"content": {"phase": "llm_reasoning", "text": f"token-{idx}"},
"metadata": {"run_id": run_id},
"timestamp": "2025-01-01T00:00:00+00:00",
},
)

repo.log_event(
conversation_id,
{
"type": "tool",
"subtype": "start",
"content": {"tool": "save_analysis", "input": "{}"},
"metadata": {"run_id": run_id},
"timestamp": "2025-01-01T00:00:00+00:00",
},
)
repo.log_event(
conversation_id,
{
"type": "tool",
"subtype": "end",
"content": {
"tool": "save_analysis",
"output": {"status": "success", "analysis_id": "asset-1"},
},
"metadata": {"run_id": run_id},
"timestamp": "2025-01-01T00:00:00+00:00",
},
)

events = repo.get_conversation_events(conversation_id, limit=200)

assert len(events) == 200
assert events[0]["sequence"] == 23
assert events[-1]["sequence"] == 222
assert any(event["type"] == "tool" and event["subtype"] == "start" for event in events)
assert any(event["type"] == "tool" and event["subtype"] == "end" for event in events)
73 changes: 61 additions & 12 deletions frontend/pluto_duck_frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { DatasetList, ProfileCard } from '../components/sidebar';
import { DatasetDetailView } from '../components/datasets';
import { AssetListView } from '../components/assets';
import { ProjectSelector, CreateProjectModal } from '../components/projects';
import { DisplayConfigModal } from '../components/editor/components/DisplayConfigModal';
import { useBoards } from '../hooks/useBoards';
import { useProjects } from '../hooks/useProjects';
import { useProjectState } from '../hooks/useProjectState';
Expand All @@ -48,6 +49,9 @@ import { useBackendStatus } from '../hooks/useBackendStatus';
type MainView = 'boards' | 'assets' | 'datasets';
type Dataset = FileAsset | CachedTable;
type Locale = 'en' | 'ko';
type PendingBoardAction =
| { type: 'markdown'; content: string }
| { type: 'asset-embed'; analysisId: string };

const SIDEBAR_COLLAPSED_KEY = 'pluto-duck-sidebar-collapsed';
const SELECTED_DATASET_ID_KEY = 'pluto_selected_dataset_id';
Expand Down Expand Up @@ -97,7 +101,8 @@ function WorkspacePageBody({
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [chatPanelCollapsed, setChatPanelCollapsed] = useState(false);
const [boardSelectorOpen, setBoardSelectorOpen] = useState(false);
const [pendingSendContent, setPendingSendContent] = useState<string | null>(null);
const [pendingBoardAction, setPendingBoardAction] = useState<PendingBoardAction | null>(null);
const [chatEmbedFlow, setChatEmbedFlow] = useState<{ analysisId: string } | null>(null);
const [sidebarTab, setSidebarTab] = useState<'boards' | 'datasets'>('boards');
const [sidebarDatasets, setSidebarDatasets] = useState<(FileAsset | CachedTable)[]>([]);
const [selectedDataset, setSelectedDataset] = useState<Dataset | null>(null);
Expand Down Expand Up @@ -608,11 +613,36 @@ function WorkspacePageBody({
boardsViewRef.current?.insertMarkdown(content);
} else {
// No board selected - show selector modal
setPendingSendContent(content);
setPendingBoardAction({ type: 'markdown', content });
setBoardSelectorOpen(true);
}
}, [activeBoard]);

const handleRequestAssetEmbed = useCallback((analysisId: string) => {
if (activeBoard) {
setChatEmbedFlow({ analysisId });
return;
}
setPendingBoardAction({ type: 'asset-embed', analysisId });
setBoardSelectorOpen(true);
}, [activeBoard]);

const handleChatEmbedConfigSave = useCallback((config: AssetEmbedConfig) => {
if (!chatEmbedFlow) {
return;
}
if (!defaultProjectId) {
console.warn('No project selected.');
return;
}
boardsViewRef.current?.insertAssetEmbed(chatEmbedFlow.analysisId, defaultProjectId, config);
setChatEmbedFlow(null);
}, [chatEmbedFlow, defaultProjectId]);

const handleChatEmbedCancel = useCallback(() => {
setChatEmbedFlow(null);
}, []);

// Handle embedding asset from chat to board
const handleEmbedAssetToBoard = useCallback((analysisId: string, config: AssetEmbedConfig) => {
if (!activeBoard) {
Expand All @@ -631,17 +661,26 @@ function WorkspacePageBody({
// Handle board selection from modal
const handleBoardSelect = useCallback((boardId: string) => {
const board = boards.find(b => b.id === boardId);
if (board) {
selectBoard(board);
// Wait for board to be selected and editor to mount, then insert content
if (pendingSendContent) {
setTimeout(() => {
boardsViewRef.current?.insertMarkdown(pendingSendContent);
setPendingSendContent(null);
}, 100);
}
if (!board) {
return;
}
selectBoard(board);

if (!pendingBoardAction) {
return;
}
}, [boards, selectBoard, pendingSendContent]);

const action = pendingBoardAction;
setPendingBoardAction(null);
// Wait for board selection + editor mount before applying the pending action.
setTimeout(() => {
if (action.type === 'markdown') {
boardsViewRef.current?.insertMarkdown(action.content);
return;
}
setChatEmbedFlow({ analysisId: action.analysisId });
}, 100);
}, [boards, selectBoard, pendingBoardAction]);

useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
Expand Down Expand Up @@ -981,6 +1020,7 @@ function WorkspacePageBody({
savedTabs={currentProject?.settings?.ui_state?.chat?.open_tabs}
savedActiveTabId={currentProject?.settings?.ui_state?.chat?.active_tab_id}
onSendToBoard={handleSendToBoard}
onRequestAssetEmbed={handleRequestAssetEmbed}
onEmbedAssetToBoard={handleEmbedAssetToBoard}
/>
</div>
Expand Down Expand Up @@ -1088,6 +1128,15 @@ function WorkspacePageBody({
boards={boards}
onSelect={handleBoardSelect}
/>
{chatEmbedFlow && defaultProjectId && (
<DisplayConfigModal
open={true}
analysisId={chatEmbedFlow.analysisId}
projectId={defaultProjectId}
onSave={handleChatEmbedConfigSave}
onCancel={handleChatEmbedCancel}
/>
)}
</div>
);
}
Expand Down

This file was deleted.

Loading