Skip to content
Draft
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
2 changes: 2 additions & 0 deletions lumen/ai/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .base import Agent
from .base_code import BaseCodeAgent
from .chat import ChatAgent
from .code_transform import CodeTransformAgent
from .dbtsl import DbtslAgent
from .document_list import DocumentListAgent
from .hvplot import hvPlotAgent
Expand All @@ -15,6 +16,7 @@
"AnalysisAgent",
"BaseCodeAgent",
"ChatAgent",
"CodeTransformAgent",
"DbtslAgent",
"DocumentListAgent",
"hvPlotAgent",
Expand Down
182 changes: 182 additions & 0 deletions lumen/ai/agents/code_transform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
from __future__ import annotations

from typing import Any

import param

from pydantic import BaseModel, Field

from ...pipeline import Pipeline
from ...sources.duckdb import DuckDBSource
from ...util import normalize_table_name
from ..code_executor import CodeSafetyCheck, PandasExecutor
from ..config import PROMPTS_DIR, UserCancelledError
from ..context import ContextModel, TContext
from ..llm import Message
from ..utils import describe_data, get_data
from ..views import LumenOutput
from .base_code import BaseCodeAgent


class TransformSpec(BaseModel):
"""LLM response model for pandas transformation code."""

chain_of_thought: str = Field(
description="Brief reasoning (1-2 sentences) for the transformation strategy."
)
table_slug: str = Field(
description=(
"Short, descriptive snake_case name for the transformed table "
"(e.g. filtered_orders_2024)."
)
Comment on lines +27 to +31

Copilot AI Jan 22, 2026

Copy link

Choose a reason for hiding this comment

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

The LLM is asked to provide a "snake_case" table_slug (line 27-31), but there's no validation that the LLM actually follows this instruction. The normalize_table_name function will fix most issues, but it also converts to lowercase and replaces special characters with underscores, potentially creating a table name that differs significantly from what the LLM intended. Consider adding validation feedback if the normalized name differs from the LLM-provided name, so the LLM can learn to provide properly formatted names.

Copilot uses AI. Check for mistakes.
)
code: str = Field(
description=(
"Python code that transforms the input DataFrame `df` into a new DataFrame "
"assigned to `df_out`. Use pandas (pd) and optionally numpy (np). "
"Do not perform any I/O."
)
)


class CodeTransformInputs(ContextModel):
data: Any
pipeline: Pipeline
table: str

Copilot AI Jan 22, 2026

Copy link

Choose a reason for hiding this comment

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

The CodeTransformInputs schema includes 'table' as a required field, but this field doesn't appear to be used anywhere in the agent implementation. The agent retrieves the table from 'pipeline.table' instead. Either remove this unused field from the schema or add validation to ensure consistency between context['table'] and context['pipeline'].table if both are expected to be present.

Suggested change
table: str

Copilot uses AI. Check for mistakes.


class CodeTransformOutputs(ContextModel):
code: str
data: Any
pipeline: Pipeline
source: DuckDBSource
table: str


class CodeTransformAgent(BaseCodeAgent):
"""
Generates pandas transformation code, executes it, and exposes the result as a DuckDB view.
"""

conditions = param.List(
default=[
"If no pipeline is available you MUST use a SQL agent to make the data available first",
"Use when the user asks to clean, reshape, or transform data that is not easily achievable in SQL",

Copilot AI Jan 22, 2026

Copy link

Choose a reason for hiding this comment

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

The condition on line 64 states "Use when the user asks to clean, reshape, or transform data that is not easily achievable in SQL". However, this condition is vague - what defines "not easily achievable in SQL"? This could lead to confusion about when to use this agent versus a SQL agent. Consider providing more specific examples or criteria, such as "Use for complex transformations requiring iterative logic, custom Python functions, or operations not supported by SQL (e.g., advanced string manipulation, custom aggregations)".

Suggested change
"Use when the user asks to clean, reshape, or transform data that is not easily achievable in SQL",
"Use when the user asks to clean, reshape, or transform data in ways that are cumbersome or unsupported in SQL (e.g. iterative or row-wise logic, custom Python functions, advanced string manipulation, complex multi-step feature engineering, or custom aggregations)",

Copilot uses AI. Check for mistakes.
"Use for feature engineering, column creation, filtering, or aggregation in pandas",
"Use when the user wants a new derived table from the current dataset",
]
)

purpose = param.String(
default="Generates pandas code to transform the current DataFrame into a new table."
)

prompts = param.Dict(
default={
"main": {
"response_model": TransformSpec,
"template": PROMPTS_DIR / "CodeTransformAgent" / "main.jinja2",
},
"code_safety": {
"response_model": CodeSafetyCheck,
"template": PROMPTS_DIR / "CodeTransformAgent" / "code_safety.jinja2",
},
}
)

user = param.String(default="Transform")

_executor_class = PandasExecutor

_output_type = LumenOutput

input_schema = CodeTransformInputs
output_schema = CodeTransformOutputs

async def _generate_code_spec(
self,
messages: list[Message],
context: TContext,
pipeline: Pipeline,
errors: list[str] | None = None,
) -> dict[str, Any] | None:
errors_context = self._build_errors_context(pipeline, context, errors)
try:
available_tables = pipeline.source.get_tables()
except Exception:
available_tables = []
Comment on lines +104 to +107

Copilot AI Jan 22, 2026

Copy link

Choose a reason for hiding this comment

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

The error handling when getting available tables wraps all exceptions with a bare except clause and returns an empty list. This could hide legitimate errors (like network issues with a remote database) and make debugging difficult. Consider logging the exception or being more specific about which exceptions to catch (e.g., only catching expected exceptions like AttributeError if the source doesn't support get_tables).

Copilot uses AI. Check for mistakes.

with self._add_step(title="Generating transformation code", steps_layout=self._steps_layout) as step:
system_prompt = await self._render_prompt(
"main",
messages,
context,
table=pipeline.table,
tables=available_tables,
**errors_context,
)

model_spec = self.prompts["main"].get("llm_spec", self.llm_spec_key)
response = self.llm.stream(
messages,
system=system_prompt,
model_spec=model_spec,
response_model=TransformSpec,
)

async for output in response:
step.stream(output.chain_of_thought, replace=True)

step.stream(f"\n```python\n{output.code}\n```\n", replace=False)

system = None
if self.code_execution == "llm":
system = await self._render_prompt(
"code_safety",
messages,
context,
code=output.code,
)

df = await get_data(pipeline)
transformed = await self._execute_code(output.code, df, system=system, step=step)

if transformed is None:
raise UserCancelledError("Code execution rejected by user.")

table_slug = normalize_table_name(output.table_slug or f"{pipeline.table}_transformed")

Copilot AI Jan 22, 2026

Copy link

Choose a reason for hiding this comment

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

The fallback table name uses string concatenation which could produce invalid table names if pipeline.table contains special characters. While normalize_table_name will clean this up, consider using a more explicit format that's clearer about the intent, such as f"{normalize_table_name(pipeline.table)}_transformed" to ensure the base table name is also normalized before concatenation.

Suggested change
table_slug = normalize_table_name(output.table_slug or f"{pipeline.table}_transformed")
table_slug = normalize_table_name(
output.table_slug or f"{normalize_table_name(pipeline.table)}_transformed"
)

Copilot uses AI. Check for mistakes.

source = DuckDBSource(uri=":memory:", mirrors={table_slug: transformed}, ephemeral=True)

Copilot AI Jan 22, 2026

Copy link

Choose a reason for hiding this comment

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

The new Pipeline created on line 150 uses the DuckDBSource with an in-memory URI (":memory:"). This creates an isolated pipeline that won't have access to any other tables from the original source. If users need to reference multiple tables or join the transformed data with other tables, they won't be able to do so. Consider documenting this limitation in the docstring, or providing a way to preserve access to other tables from the original source.

Suggested change
source = DuckDBSource(uri=":memory:", mirrors={table_slug: transformed}, ephemeral=True)
# Reuse the original DuckDBSource URI when possible so that the new pipeline
# can still access other tables from the original source. Fall back to an
# in-memory database if the original source is not a DuckDBSource or does
# not expose a URI.
source_uri = ":memory:"
if isinstance(pipeline.source, DuckDBSource):
try:
source_uri = pipeline.source.uri # type: ignore[attr-defined]
except AttributeError:
source_uri = ":memory:"
source = DuckDBSource(uri=source_uri, mirrors={table_slug: transformed}, ephemeral=True)

Copilot uses AI. Check for mistakes.
new_pipeline = Pipeline(source=source, table=table_slug)

return {
"code": output.code,
"pipeline": new_pipeline,
"source": source,
"table": table_slug,
"data": transformed,
}

async def respond(
self,
messages: list[Message],
context: TContext,
step_title: str | None = None,
) -> tuple[list[Any], CodeTransformOutputs]:
pipeline = context.get("pipeline")
if not pipeline:
raise ValueError("Context did not contain a pipeline.")

result = await self._generate_code_spec(messages, context, pipeline)
if result is None:
return [], {}
Comment on lines +171 to +172

Copilot AI Jan 22, 2026

Copy link

Choose a reason for hiding this comment

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

When the result from _generate_code_spec is None (line 171), the method returns an empty list and empty dict. However, the return type annotation indicates it should return CodeTransformOutputs, not an empty dict. This type inconsistency could cause issues for code that expects a properly typed output. Consider either raising an exception or returning a properly structured CodeTransformOutputs instance with None/default values.

Copilot uses AI. Check for mistakes.

out = self._output_type(component=result["pipeline"], title=step_title)
out_context = {
"code": result["code"],
"pipeline": result["pipeline"],
"source": result["source"],
"table": result["table"],
"data": await describe_data(result["data"]),
Comment on lines +175 to +180

Copilot AI Jan 22, 2026

Copy link

Choose a reason for hiding this comment

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

The output context on line 180 calls describe_data on the transformed data, but there's no error handling if describe_data fails. Given that this is a new DataFrame created by LLM-generated code, it could potentially have unexpected types or structures that might cause describe_data to fail. Consider wrapping this call in a try-except block to gracefully handle any potential errors, similar to the pattern used elsewhere in the codebase.

Suggested change
out_context = {
"code": result["code"],
"pipeline": result["pipeline"],
"source": result["source"],
"table": result["table"],
"data": await describe_data(result["data"]),
try:
described_data = await describe_data(result["data"])
except Exception:
described_data = result["data"]
out_context = {
"code": result["code"],
"pipeline": result["pipeline"],
"source": result["source"],
"table": result["table"],
"data": described_data,

Copilot uses AI. Check for mistakes.
}
return [out], out_context
Comment on lines +56 to +182

Copilot AI Jan 22, 2026

Copy link

Choose a reason for hiding this comment

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

The new CodeTransformAgent lacks test coverage. The test file lumen/tests/ai/test_agents.py contains tests for other agents (ChatAgent, SQLAgent, VegaLiteAgent, AnalysisAgent), but no tests exist for CodeTransformAgent. At minimum, tests should cover: basic transformation execution, error handling when pipeline is missing, table_slug normalization, and validation that the transformed data is correctly exposed as a DuckDB view.

Copilot uses AI. Check for mistakes.
22 changes: 22 additions & 0 deletions lumen/ai/code_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,3 +374,25 @@ def _validate_result(cls, result: Any) -> None:
import pydeck as pdk
if not isinstance(result, pdk.Deck):
raise ValueError(f"'deck' must be a pydeck.Deck, got {type(result).__name__}")


class PandasExecutor(CodeExecutor):
"""Safe executor for LLM-generated pandas transformations."""

allowed_imports = ('pandas', 'pd', 'numpy', 'np')
allowed_import_prefixes = ('pandas', 'numpy')
output_variable = 'df_out'

@classmethod
def _get_injected_modules(cls) -> dict[str, Any]:
import numpy as np
import pandas as pd
return {'pd': pd, 'pandas': pd, 'np': np, 'numpy': np}
Comment on lines +387 to +390

Copilot AI Jan 22, 2026

Copy link

Choose a reason for hiding this comment

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

The _get_injected_modules method includes redundant module mappings. Both 'pd' and 'pandas' map to the same pandas module, and similarly 'np' and 'numpy' map to the same numpy module. While this provides flexibility for users, it's inconsistent with the pattern used in other executors in this file (e.g., AltairExecutor only maps 'alt' and 'altair', not the full module name twice). Consider whether all four mappings are necessary, or if just the common aliases ('pd', 'np') would suffice to maintain consistency with the established patterns.

Copilot uses AI. Check for mistakes.

@classmethod
def _validate_result(cls, result: Any) -> None:
import pandas as pd
if not isinstance(result, pd.DataFrame):
raise ValueError(
f"'df_out' must be a pandas.DataFrame, got {type(result).__name__}"
)
10 changes: 10 additions & 0 deletions lumen/ai/prompts/CodeTransformAgent/code_safety.jinja2
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% extends 'BaseViewAgent/code_safety.jinja2' %}

{% set library_name = 'pandas' %}

{% block safe_items %}
- Pandas operations on the provided `df` DataFrame
- Numpy operations for vectorized math (`np`)
- Assignment to `df_out`
{{ super() }}
{% endblock %}
22 changes: 22 additions & 0 deletions lumen/ai/prompts/CodeTransformAgent/main.jinja2
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% extends 'Actor/main.jinja2' %}

{% block instructions %}
Generate Python pandas code to transform the provided DataFrame `df`.

Requirements:
- Use pandas (`pd`) and optionally numpy (`np`); both are available.
- Assign the final transformed DataFrame to `df_out`.

Copilot AI Jan 22, 2026

Copy link

Choose a reason for hiding this comment

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

The prompt instruction states "Assign the final transformed DataFrame to df_out" but doesn't explicitly mention that the input DataFrame is available as df. While this might be implied, being explicit about both the input variable name (df) and output variable name (df_out) would improve clarity and reduce potential confusion for the LLM.

Suggested change
- Assign the final transformed DataFrame to `df_out`.
- The input DataFrame is available as `df`; assign the final transformed DataFrame to `df_out`.

Copilot uses AI. Check for mistakes.
- Avoid I/O, plotting, network calls, or system access.
- Keep transformations deterministic and focused on the user's request.
- Provide a short, descriptive snake_case `table_slug` for the transformed data.
{% endblock %}

{% block context %}
Current table: {{ memory['pipeline'].table }}
{% if tables is defined and tables %}
Existing tables: {{ tables }}
{% endif %}

Dataset summary:
{{ memory['data'] }}
{% endblock %}
Loading