Skip to content

Commit cc3b7f6

Browse files
mikegrosluiarthur
andauthored
Updates to add the web dashboard plus some minor bug fixes (#192)
* Updates to add the web dashboard plus some minor bug fixes This commit has 3 changes (sorry for putting more than one per PR): Major: - Committing the web dashboard into the repo. It can now be launched with: `ursa_dashboard` or `ursa_dashboard --host 127.0.0.1 --port 8080` Sessions are stored in `Path.home() / .cache/ursa_dashboard_workspace` This seems to work well and while a review would be great, there is definitely some extra stuff around in it that we should clean out later but should commit now to make the working web interface available to anyone. Minor: - Fix to the dangling tool issue - There were still rare but possible reasons to get dangling tools in checkpointing or around summarization. This is fixed now for the execution agent: Before invoking the LLM the message history is checked for dangling tools and if any exist, a ToolMessage is inserted to fix it. - Plan-Execute workflow has checkpointing switched from SQL to InMemory. We should probably change this back in the long run but involves handling the swap between Sync and Async checkpointing that we should push down the road. - Small changes about checking for async tools to allow the web dashboard to work properly with MCP tools. * Update the README with basic web dashboard launch commands. * Changing dashboard default model and max tokens. * Update src/ursa/workflows/planning_execution_workflow.py Co-authored-by: Arthur Lui <5297817+luiarthur@users.noreply.github.com> * Update src/ursa_dashboard/adapters.py Co-authored-by: Arthur Lui <5297817+luiarthur@users.noreply.github.com> * Update src/ursa_dashboard/api_models.py Co-authored-by: Arthur Lui <5297817+luiarthur@users.noreply.github.com> * Update src/ursa_dashboard/api_models.py Co-authored-by: Arthur Lui <5297817+luiarthur@users.noreply.github.com> * Update src/ursa_dashboard/artifacts.py Co-authored-by: Arthur Lui <5297817+luiarthur@users.noreply.github.com> * Update src/ursa_dashboard/artifacts.py Co-authored-by: Arthur Lui <5297817+luiarthur@users.noreply.github.com> * Update pyproject.toml Co-authored-by: Arthur Lui <5297817+luiarthur@users.noreply.github.com> * Update README.md Co-authored-by: Arthur Lui <5297817+luiarthur@users.noreply.github.com> * Update README.md Co-authored-by: Arthur Lui <5297817+luiarthur@users.noreply.github.com> * Better name for looping index: msg_ind instead of iii * Updating comment about dangling tool fix * Remove trailing whitespace failing ruff check --------- Co-authored-by: Arthur Lui <5297817+luiarthur@users.noreply.github.com>
1 parent 2c95a7c commit cc3b7f6

26 files changed

+6815
-14
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,20 @@ You can get a list of available command line options via
119119
ursa --help
120120
```
121121

122+
### Web Dashboard
123+
124+
The URSA web interface can be launched with:
125+
```
126+
ursa-dashboard
127+
```
128+
129+
or with
130+
```
131+
ursa-dashboard --host 127.0.0.1 --port 8080
132+
```
133+
134+
This requires installing with the optional `[dashboard]` dependencies.
135+
122136
### Configuring URSA
123137

124138
See the example [configuration file](./configs/example.yaml) and [documentation](./configs/README.md) for more details.

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ classifiers = [
5959

6060
[project.scripts]
6161
ursa = "ursa.cli:main"
62+
ursa-dashboard = "ursa_dashboard.main:app"
6263

6364
[project.urls]
6465
Homepage = "https://github.com/lanl/ursa"
@@ -67,6 +68,10 @@ Repository = "https://github.com/lanl/ursa"
6768
Issues = "https://github.com/lanl/ursa/issues"
6869

6970
[project.optional-dependencies]
71+
dashboard = [
72+
"fastapi>=0.120.0",
73+
"uvicorn>=0.37.0",
74+
]
7075
fm = [
7176
"torch>=2.9.0",
7277
] # Ursa's MCP FM interface

src/ursa/agents/base.py

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
integration capabilities while only needing to implement the core _invoke method.
1616
"""
1717

18+
import asyncio
1819
import re
1920
import sqlite3
2021
from abc import ABC, abstractmethod
@@ -591,11 +592,74 @@ def _finalize_graph(
591592
"""Hook for subclasses to wrap or modify the compiled graph."""
592593
return graph_app
593594

595+
def _tool_is_async_only(self, tool: Any) -> bool:
596+
"""Return True for tools that can only be invoked asynchronously.
597+
598+
MCP tools are commonly exposed as StructuredTool instances with a
599+
coroutine implementation but no synchronous function implementation.
600+
Those raise errors like:
601+
602+
"StructuredTool does not support sync invocation."
603+
604+
when called via `.invoke()`.
605+
"""
606+
607+
func = getattr(tool, "func", None)
608+
coroutine = getattr(tool, "coroutine", None)
609+
return func is None and coroutine is not None
610+
611+
def _has_async_only_tools(self) -> bool:
612+
tools_obj = getattr(self, "tools", None)
613+
if not tools_obj:
614+
return False
615+
616+
try:
617+
tool_iter = (
618+
tools_obj.values() if isinstance(tools_obj, dict) else tools_obj
619+
)
620+
except Exception:
621+
return False
622+
623+
return any(self._tool_is_async_only(t) for t in tool_iter)
624+
594625
def _invoke(self, input, **config):
595626
config = self.build_config(**config)
596-
return self.compiled_graph.invoke(
597-
input, config=config, context=self.context
598-
)
627+
628+
# If we have async-only tools (e.g. MCP StructuredTools), we must run the
629+
# graph via `ainvoke` so ToolNode dispatches tools asynchronously.
630+
if self._has_async_only_tools():
631+
try:
632+
asyncio.get_running_loop()
633+
except RuntimeError:
634+
return asyncio.run(
635+
self.compiled_graph.ainvoke(
636+
input, config=config, context=self.context
637+
)
638+
)
639+
640+
raise RuntimeError(
641+
"This agent has async-only tools, but `.invoke()` was called "
642+
"from an async context (a running event loop was detected). "
643+
"Use `await agent.ainvoke(...)` instead."
644+
)
645+
646+
try:
647+
return self.compiled_graph.invoke(
648+
input, config=config, context=self.context
649+
)
650+
except Exception as e:
651+
# Fallback: if a tool raises the canonical sync-invoke error, retry
652+
# with ainvoke for backwards compatibility.
653+
if "does not support sync invocation" in str(e):
654+
try:
655+
asyncio.get_running_loop()
656+
except RuntimeError:
657+
return asyncio.run(
658+
self.compiled_graph.ainvoke(
659+
input, config=config, context=self.context
660+
)
661+
)
662+
raise
599663

600664
async def _ainvoke(self, input, **config):
601665
config = self.build_config(**config)

src/ursa/agents/execution_agent.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,44 @@ def __init__(
221221
self.tokens_before_summarize = tokens_before_summarize
222222
self.messages_to_keep = messages_to_keep
223223

224+
def _patch_dangling(self, state: ExecutionState) -> ExecutionState:
225+
new_state = deepcopy(state)
226+
summarized = False
227+
dangling_response = (
228+
"Response Not Found from tool. "
229+
"May have timed out or been forgotten due to summarization."
230+
)
231+
232+
tool_ids = []
233+
for msg in new_state["messages"]:
234+
if hasattr(msg, "tool_calls"):
235+
for call in msg.tool_calls:
236+
tool_ids.append(call["id"])
237+
if isinstance(msg, ToolMessage):
238+
tool_ids.remove(msg.tool_call_id)
239+
if tool_ids:
240+
summarized = True
241+
print(
242+
f"[Dangling Tool Call Warning] The following tool IDs "
243+
f"were dangling:\n{tool_ids}\nReplies of missing response applied."
244+
)
245+
for tool_id in tool_ids:
246+
for msg_ind, msg in enumerate(new_state["messages"]):
247+
if hasattr(msg, "tool_calls"):
248+
if any([tc["id"] == tool_id for tc in msg.tool_calls]):
249+
# Inserts tool response one after the dangling tool call
250+
# Mutates new_state so break afterward to reset loop.
251+
# Not as efficient as could be but should be correct
252+
new_state["messages"].insert(
253+
msg_ind + 1,
254+
ToolMessage(
255+
content=dangling_response,
256+
tool_call_id=tool_id,
257+
),
258+
)
259+
break
260+
return new_state, summarized
261+
224262
# Check message history length and summarize to shorten the token usage:
225263
def _summarize_context(self, state: ExecutionState) -> ExecutionState:
226264
new_state = deepcopy(state)
@@ -357,6 +395,8 @@ def query_executor(
357395
new_state["symlinkdir"]["is_linked"] = True
358396
full_overwrite = True
359397

398+
new_state, full_overwrite = self._patch_dangling(new_state)
399+
360400
# 3) Ensure the executor prompt is the first SystemMessage.
361401
messages = deepcopy(new_state["messages"])
362402
if isinstance(messages[0], SystemMessage):
@@ -412,6 +452,7 @@ def recap(self, state: ExecutionState) -> ExecutionState:
412452
new_state["messages"] = new_state["messages"] + [recap_message]
413453

414454
# 2) Invoke the LLM to generate a recap; capture content even on failure.
455+
new_state, full_overwrite = self._patch_dangling(new_state)
415456
try:
416457
response = self.llm.invoke(
417458
input=new_state["messages"],

src/ursa/util/helperFunctions.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import asyncio
34
import json
45
import uuid
56
from typing import Any, Callable, Iterable
@@ -76,9 +77,35 @@ def _stringify_output(x: Any) -> str:
7677
def _invoke_tool(
7778
tool: Runnable | Callable[..., Any], args: dict[str, Any]
7879
) -> Any:
80+
"""Invoke a tool from a synchronous context.
81+
82+
Some tool implementations (notably MCP-adapted tools exposed as
83+
StructuredTool instances) are *async-only* and will raise an error like
84+
"... does not support sync invocation" when `.invoke()` is used.
85+
86+
For those tools we fall back to running `.ainvoke()` in a fresh event
87+
loop when possible.
88+
"""
89+
7990
# Runnable (LangChain tools & chains)
8091
if isinstance(tool, Runnable):
81-
return tool.invoke(args)
92+
try:
93+
return tool.invoke(args)
94+
except Exception as e:
95+
if "does not support sync invocation" in str(e) and hasattr(
96+
tool, "ainvoke"
97+
):
98+
try:
99+
asyncio.get_running_loop()
100+
except RuntimeError:
101+
return asyncio.run(tool.ainvoke(args))
102+
103+
raise RuntimeError(
104+
"Async-only tool was invoked from within a running event loop. "
105+
"Use `await tool.ainvoke(...)` (or provide an async caller) instead."
106+
) from e
107+
raise
108+
82109
# Plain callable
83110
try:
84111
return tool(**args)

src/ursa/workflows/planning_execution_workflow.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import sqlite3
2-
from pathlib import Path
3-
4-
from langgraph.checkpoint.sqlite import SqliteSaver
1+
from langgraph.checkpoint.memory import InMemorySaver
52
from rich import get_console
63
from rich.panel import Panel
74

@@ -24,14 +21,17 @@ def __init__(self, planner, executor, workspace, **kwargs):
2421
self.executor = executor
2522
self.workspace = workspace
2623

24+
# FIXME: DOES NOT CURRENTLY WORK IN WEB INTERFACE WITH
25+
# SQL checkpointing
26+
# MOVING TO IN MEMORY CHECKPOINTING FOR NOW
2727
# Setup checkpointing
28-
db_path = Path(workspace) / "checkpoint.db"
29-
db_path.parent.mkdir(parents=True, exist_ok=True)
30-
conn = sqlite3.connect(str(db_path), check_same_thread=False)
31-
checkpointer = SqliteSaver(conn)
28+
# db_path = Path(workspace) / "checkpoint.db"
29+
# db_path.parent.mkdir(parents=True, exist_ok=True)
30+
# conn = sqlite3.connect(str(db_path), check_same_thread=False)
31+
# checkpointer = SqliteSaver(conn)
3232

33-
self.planner.checkpointer = checkpointer
34-
self.executor.checkpointer = checkpointer
33+
self.planner.checkpointer = InMemorySaver()
34+
self.executor.checkpointer = InMemorySaver()
3535

3636
def _invoke(self, task: str, **kw):
3737
with console.status(

src/ursa_dashboard/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""URSA Dashboard package.
2+
3+
This package provides web-dashboard plumbing around URSA agents:
4+
5+
- Agent registry with parameter schemas + capability flags.
6+
- Adapters to execute heterogeneous agents behind a common interface.
7+
8+
Other dashboard subsystems (run orchestration, SSE streaming, artifact serving)
9+
should build on the contracts defined in prior steps.
10+
"""
11+
12+
from .registry import REGISTRY
13+
14+
__all__ = ["REGISTRY"]

src/ursa_dashboard/__main__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from __future__ import annotations
2+
3+
from .cli import main
4+
5+
if __name__ == "__main__":
6+
raise SystemExit(main())

0 commit comments

Comments
 (0)