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
35 changes: 24 additions & 11 deletions agent/src/agent/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,21 @@ async def chat_with_agent(
}
)

result = await agent_graph.ainvoke(Command(
update={
"last_error": None,
"trino_error": None,
"escalated": None,
"escalation_reason": None,
"refinement_count": 0,
},
resume=resume_value
), config=config)
is_node_interrupt = False
if getattr(state_snapshot, "interrupts", None):
is_node_interrupt = True
elif getattr(state_snapshot, "tasks", None):
if any(getattr(task, "interrupts", None) for task in state_snapshot.tasks):
is_node_interrupt = True

# Resume the graph execution depending on how it was interrupted
if is_node_interrupt:
# Interrupted by the `interrupt()` function inside a node
result = await agent_graph.ainvoke(Command(resume=resume_value), config=config)
else:
# Interrupted by an `interrupt_before` breakpoint
await agent_graph.aupdate_state(config, resume_value)
result = await agent_graph.ainvoke(None, config=config)
else:
if not query:
return json.dumps({"error": "Query is required for new chat session."})
Expand Down Expand Up @@ -142,8 +147,16 @@ async def chat_with_agent(
final_state = await agent_graph.aget_state(config)

# Check if interrupted by `interrupt()` function
if final_state.interrupts:
interrupt_val = None
if getattr(final_state, "interrupts", None):
interrupt_val = final_state.interrupts[-1].value
elif getattr(final_state, "tasks", None):
for task in final_state.tasks:
if getattr(task, "interrupts", None):
interrupt_val = task.interrupts[-1].value
break

if interrupt_val is not None:
return json.dumps(
{
"thread_id": thread_id,
Expand Down
2 changes: 2 additions & 0 deletions agent/src/agent/nodes/query_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from agent.config import settings
from agent.langfuse_client import langfuse_client
from langgraph.types import interrupt


async def query_builder_node(state: AgentState, config: RunnableConfig | None = None):
"""Build SQL from plan and pause for user approval."""
runtime_flags = state.get("runtime_flags") or {}
Expand Down
5 changes: 3 additions & 2 deletions agent/src/agent/nodes/refiner.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ async def refiner_node(state: AgentState, config: RunnableConfig | None = None):
"error": trino_error,
"schema_context": schema_context,
"error_history": json.dumps(error_history),
"user_query": state.get("user_query", ""),
}
)
new_sql = clean_sql(response.content)
Expand Down Expand Up @@ -140,8 +141,8 @@ async def refiner_node(state: AgentState, config: RunnableConfig | None = None):
langfuse_client.update_current_span(
level="ERROR", status_message=f"ESCA write failed: {e}"
)
else:
logging.error(f"ESCA write failed: {e}")

logging.error(f"ESCA write failed: {e}")
raise RuntimeError(f"Failed to write query result to ESCA: {e}")

return {
Expand Down
3 changes: 2 additions & 1 deletion agent/src/agent/nodes/satisfaction_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ async def satisfaction_check_node(state: AgentState, config: RunnableConfig | No
failures.append(f"[CHECK_A] Execution failed: {state['trino_error']}")

# ── Check B: Row Plausibility ─────────────────────────────────────────────
if _f(runtime_flags, "SATISFACTION_CHECK_PLAUSIBILITY", settings.SATISFACTION_CHECK_PLAUSIBILITY):
# Only run plausibility if there wasn't a hard execution failure
if not state.get("trino_error") and _f(runtime_flags, "SATISFACTION_CHECK_PLAUSIBILITY", settings.SATISFACTION_CHECK_PLAUSIBILITY):
n = len(rows)
min_rows = _f(runtime_flags, "SATISFACTION_MIN_ROWS", settings.SATISFACTION_MIN_ROWS)
max_rows = _f(runtime_flags, "SATISFACTION_MAX_ROWS", settings.SATISFACTION_MAX_ROWS)
Expand Down
36 changes: 28 additions & 8 deletions agent/src/agent/nodes/schema_explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,22 @@ def _build_column_context(cp: "ColumnProfile") -> dict:
return col


class TableJoin(BaseModel):
source_table: str = Field(description="Fully qualified name of the source table (catalog.schema.name)")
target_table: str = Field(description="Fully qualified name of the target table (catalog.schema.name)")
join_condition: str = Field(description="SQL condition for the join (e.g., A.id = B.a_id)")

class SchemaPlan(BaseModel):
tables: List[str] = Field(description="List of fully qualified table names (catalog.schema.name) required for the query")
columns: List[str] = Field(description="Columns to select")
joins: List[TableJoin] = Field(default_factory=list, description="How to join the tables")
filters: List[str] = Field(default_factory=list, description="Any WHERE clause filters")

# Define standardized Schema Explorer Output Type
class SchemaExplorerOutput(BaseModel):
schema_plan: Optional[Any] = Field(
reasoning: str = Field(default_factory=str, description="Reasoning for the schema plan - explain your logic.")

schema_plan: Optional[SchemaPlan] = Field(
default=None,
description="Detailed query plan describing tables, columns, and joins.",
)
Expand All @@ -156,6 +169,10 @@ class SchemaExplorerOutput(BaseModel):
default_factory=list,
description="List of fully qualified table names (catalog.schema.name) used in the plan.",
)
error: Optional[str] = Field(
default_factory=str,
description="Error message if any"
)
Comment on lines +172 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Set default to None for optional fields.

Since error is typed as Optional[str], using default_factory=str initializes it to an empty string "" instead of None. It is more idiomatic and semantically accurate to use default=None for optional error fields.

♻️ Proposed refactor
     error: Optional[str] = Field(
-        default_factory=str, 
+        default=None, 
         description="Error message if any"
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
error: Optional[str] = Field(
default_factory=str,
description="Error message if any"
)
error: Optional[str] = Field(
default=None,
description="Error message if any"
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent/src/agent/nodes/schema_explorer.py` around lines 172 - 175, Update the
error field definition to use default=None instead of default_factory=str,
preserving its Optional[str] type and existing description.



def get_query_embedding(text: str) -> list[float]:
Expand Down Expand Up @@ -189,7 +206,7 @@ def hybrid_search_tables(
all_tables = session.exec(stmt_all).all()

allowed = allowed_tables or []
statuses = allowed_statuses or ["production"]
statuses = allowed_statuses if allowed_statuses is not None else ["production"]
allowed_tables_set = []
allowed_ids = set()

Expand Down Expand Up @@ -430,7 +447,7 @@ def _parse_bool_flag(value) -> bool:

sem = asyncio.Semaphore(profile_fetch_concurrency)

async def fetch_profile(t_id, t_name):
async def fetch_profile(t_id, t_name, t_fqn):
nonlocal cache_hit_count, cache_miss_count
async with sem:
try:
Expand All @@ -453,22 +470,25 @@ async def fetch_profile(t_id, t_name):
cache_miss_count += 1

profile_res = await get_table_profile.ainvoke({"table_id": t_id})
return json.loads(profile_res)
data = json.loads(profile_res)
data["fully_qualified_name"] = t_fqn
return data
except Exception as e:
print(f"Error fetching profile for {t_name}: {e}")
return None

fetch_tasks = []
for i, t in enumerate(candidate_tables):
fqn = f"{t.catalog}.{t.schema_name}.{t.name}"
tables_info.append(
{
"id": t.id,
"name": f"{t.catalog}.{t.schema_name}.{t.name}",
"name": fqn,
"description": "",
}
)
if i < max_profiles_to_fetch:
fetch_tasks.append(fetch_profile(t.id, t.name))
fetch_tasks.append(fetch_profile(t.id, t.name, fqn))

if fetch_tasks:
results = await asyncio.gather(*fetch_tasks, return_exceptions=True)
Expand Down Expand Up @@ -532,7 +552,7 @@ async def fetch_profile(t_id, t_name):
if schema_summarization and profile_details:
try:
summaries = [
f"[{p.get('table_name', 'unknown')}] {p.get('description', '') or '(no description available)'}"
f"[{p.get('fully_qualified_name', p.get('table_name', 'unknown'))}] {p.get('description', '') or '(no description available)'}"
for p in profile_details
]
profiles_json_str = "\n".join(summaries)
Expand Down Expand Up @@ -599,7 +619,7 @@ async def fetch_profile(t_id, t_name):

plan = data.schema_plan
if plan is not None and not isinstance(plan, str):
plan = json.dumps(plan)
plan = plan.model_dump_json() if hasattr(plan, "model_dump_json") else json.dumps(plan)
elif plan is None:
plan = ""

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Add unique constraint to tables

Revision ID: 70e8a34ff877
Revises: f9a3d1c8e205
Create Date: 2026-07-07 11:17:43.459879

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
import sqlmodel


# revision identifiers, used by Alembic.
revision: str = '70e8a34ff877'
down_revision: Union[str, None] = 'f9a3d1c8e205'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('uq_table_profiles_table_id', 'table_profiles', type_='unique')
op.drop_index('ix_table_profiles_table_id', table_name='table_profiles')
op.create_index(op.f('ix_table_profiles_table_id'), 'table_profiles', ['table_id'], unique=True)
op.drop_column('table_profiles', 'is_partial')
op.create_unique_constraint('uq_table_fqn', 'tables', ['catalog', 'schema_name', 'name'])
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('uq_table_fqn', 'tables', type_='unique')
op.add_column('table_profiles', sa.Column('is_partial', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False))
op.drop_index(op.f('ix_table_profiles_table_id'), table_name='table_profiles')
op.create_index('ix_table_profiles_table_id', 'table_profiles', ['table_id'], unique=False)
op.create_unique_constraint('uq_table_profiles_table_id', 'table_profiles', ['table_id'], postgresql_nulls_not_distinct=False)
# ### end Alembic commands ###
1 change: 1 addition & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Settings(BaseSettings):
LANGFUSE_PUBLIC_KEY: str = ""
LANGFUSE_SECRET_KEY: str = ""
LANGFUSE_HOST: str = "https://cloud.langfuse.com"
LANGFUSE_REQUEST_TIMEOUT: float = 30.0
CORS_ORIGINS: list[str] = [
"http://localhost:5173",
"http://localhost:3000",
Expand Down
17 changes: 15 additions & 2 deletions backend/app/routers/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ class ChatResponse(BaseModel):
trace_id: str | None = None
execution_path: list[str] | None = None

class TraceSpan(BaseModel):
span_name: str
start_time: str | None = None
duration_ms: int = 0
input_tokens: int = 0
output_tokens: int = 0
model: str = "N/A"
status: str
input_preview: str = ""
output_preview: str = ""
Comment on lines +70 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

span_name can be None from Langfuse data, but the model requires str — one bad observation 500s the whole endpoint.

obs.get("name") or obs.get("type") returns None if both are missing/falsy, but TraceSpan.span_name: str has no default and isn't Optional. Since the endpoint uses response_model=list[TraceSpan], FastAPI will raise a ResponseValidationError (500) when serializing the response if any observation hits this case, breaking the trace timeline for the whole trace instead of just skipping/labeling that one span.

🛡️ Proposed fix
             timeline.append(
                 {
-                    "span_name": obs.get("name") or obs.get("type"),
+                    "span_name": obs.get("name") or obs.get("type") or "unknown",

Also applies to: 304-316

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/routers/agent.py` around lines 70 - 79, Update the TraceSpan
construction and its response model handling so a missing Langfuse observation
name/type never produces a None span_name or response validation failure.
Provide a non-null fallback label when both values are absent, preserving the
existing name/type preference and ensuring all entries returned by the trace
endpoint satisfy TraceSpan.span_name: str.



# ---------------------------------------------------------------------------
# Helpers
Expand Down Expand Up @@ -187,6 +198,8 @@ async def chat(request: ChatRequest) -> ChatResponse:
"allowed_tables": request.allowed_tables,
"allowed_statuses": request.allowed_statuses,
"extractors": request.extractors,
"active_skills": request.active_skills,
"execution_mode": request.execution_mode,
"hitl_enabled": request.hitl_enabled,
}

Expand Down Expand Up @@ -254,13 +267,13 @@ async def event_generator():
return StreamingResponse(event_generator(), media_type="text/event-stream")


@router.get("/traces/{trace_id}")
@router.get("/traces/{trace_id}", response_model=list[TraceSpan])
async def get_trace_timeline(trace_id: str):
"""Fetch trace from Langfuse and normalize observations for frontend timeline."""
auth = (settings.LANGFUSE_PUBLIC_KEY, settings.LANGFUSE_SECRET_KEY)
url = f"{settings.LANGFUSE_HOST}/api/public/traces/{trace_id}"

async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(timeout=settings.LANGFUSE_REQUEST_TIMEOUT) as client:
resp = await client.get(url, auth=auth)
if resp.status_code != 200:
if resp.status_code == 404:
Expand Down
14 changes: 14 additions & 0 deletions backend/app/routers/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,20 @@ def create_table(payload: TableCreate, session: Session = Depends(get_session)):
text_to_embed = f"Table name: {name}\nSchema: {schema_name}\nDescription: {description}\nColumns: {', '.join([c.get('name', '') for c in om_columns])}"
embedding = get_embedding(text_to_embed)

# Check for duplicate table
existing = session.exec(
select(Table).where(
Table.catalog == catalog_name,
Table.schema_name == schema_name,
Table.name == name
)
).first()
if existing:
raise HTTPException(
status_code=409,
detail=f"Table '{catalog_name}.{schema_name}.{name}' already exists."
)

# Create the table
table = Table(
name=name,
Expand Down
5 changes: 4 additions & 1 deletion core/src/core/models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from enum import StrEnum
from typing import Any, Literal

from sqlalchemy import JSON, Column, ForeignKey
from sqlalchemy import JSON, Column, ForeignKey, UniqueConstraint
from sqlmodel import Field, SQLModel, Relationship
from pgvector.sqlalchemy import Vector

Expand All @@ -26,6 +26,9 @@ class FeatureFlagType(StrEnum):

class Table(SQLModel, table=True):
__tablename__ = "tables"
__table_args__ = (
UniqueConstraint("catalog", "schema_name", "name", name="uq_table_fqn"),
)

id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
name: str = Field(index=True)
Expand Down
Loading