diff --git a/fastapi_gen/cli.py b/fastapi_gen/cli.py index ca3cabbc..940c02d1 100644 --- a/fastapi_gen/cli.py +++ b/fastapi_gen/cli.py @@ -478,6 +478,18 @@ def new(output: Path | None, no_input: bool, name: str | None, minimal: bool) -> help="Enable AntV advanced-diagram tools (flowchart, mind-map, sankey, ...) via an " "mcp-server-chart sidecar, plus an interactive Leaflet/OpenStreetMap map tool", ) +@click.option( + "--code-execution", + is_flag=True, + default=False, + help="Enable the run_python code-execution tool backed by the Monty sandbox (PydanticAI only)", +) +@click.option( + "--skills", + is_flag=True, + default=False, + help="Enable the skills system (SkillsToolset loads SKILL.md files from backend/skills/, PydanticAI only)", +) @click.option("--session-management", is_flag=True, help="Enable session management") @click.option( "--reverse-proxy", @@ -754,6 +766,8 @@ def create( web_fetch: bool, charts: bool, antv_charts: bool, + code_execution: bool, + skills: bool, session_management: bool, reverse_proxy: str, kubernetes: bool, @@ -1183,6 +1197,8 @@ def create( enable_web_fetch=web_fetch, enable_charts=charts, enable_antv_charts=antv_charts, + enable_code_execution=code_execution, + enable_skills=skills, enable_session_management=session_management, reverse_proxy=_rp_map[reverse_proxy], enable_kubernetes=kubernetes, diff --git a/fastapi_gen/config.py b/fastapi_gen/config.py index aa79d044..73268186 100644 --- a/fastapi_gen/config.py +++ b/fastapi_gen/config.py @@ -326,6 +326,8 @@ class ProjectConfig(BaseModel): enable_web_fetch: bool = False enable_charts: bool = False enable_antv_charts: bool = False + enable_code_execution: bool = False + enable_skills: bool = False use_telegram: bool = False use_slack: bool = False enable_cors: bool = True @@ -813,6 +815,8 @@ def to_cookiecutter_context(self) -> dict[str, Any]: "enable_charts": self.enable_charts, "charts_channel_png": self.enable_charts and (self.use_slack or self.use_telegram), "enable_antv_charts": self.enable_antv_charts, + "enable_code_execution": self.enable_code_execution, + "enable_skills": self.enable_skills, "enable_webhooks": self.enable_webhooks, # Legacy fixed values (WebSocket always uses JWT) "websocket_auth": "jwt", diff --git a/fastapi_gen/prompts.py b/fastapi_gen/prompts.py index 138959d9..2afbb958 100644 --- a/fastapi_gen/prompts.py +++ b/fastapi_gen/prompts.py @@ -924,6 +924,52 @@ def prompt_antv_charts() -> bool: ) +def prompt_code_execution() -> bool: + """Prompt for the Monty-backed run_python code-execution tool (PydanticAI only).""" + console.print() + console.print("[bold cyan]Code Execution (Monty sandbox)[/]") + console.print( + "Adds a `run_python` tool that lets the agent execute Python in a sandboxed " + "interpreter (pydantic-monty). The model can compute projections, run " + "aggregations, and call create_chart/create_map directly from inside the " + "code. Restricted stdlib: math, asyncio, json, datetime, re — no numpy/pandas. " + "PydanticAI only. Activate at runtime with ENABLE_CODE_EXECUTION=true." + ) + console.print() + + return cast( + bool, + _check_cancelled( + questionary.confirm( + "Enable the run_python code-execution tool (PydanticAI only)?", + default=False, + ).ask() + ), + ) + + +def prompt_skills() -> bool: + """Prompt for the skills system (SkillsToolset, PydanticAI only).""" + console.print() + console.print("[bold cyan]Skills System (pydantic-ai-skills)[/]") + console.print( + "Adds a SkillsToolset that loads SKILL.md files from `backend/skills/` as " + "agent tools. Drop in your own skills; pair with code execution (Monty " + "sandbox) for skills that compute. PydanticAI only." + ) + console.print() + + return cast( + bool, + _check_cancelled( + questionary.confirm( + "Enable the skills system (PydanticAI only)?", + default=False, + ).ask() + ), + ) + + def prompt_langsmith() -> bool: """Prompt for LangSmith observability.""" return cast( @@ -1434,6 +1480,8 @@ def run_interactive_prompts() -> ProjectConfig: "enable_web_fetch": False, "enable_charts": False, "enable_antv_charts": False, + "enable_code_execution": False, + "enable_skills": False, "rag_features": RAGFeatures(), "orm_type": OrmType.SQLALCHEMY, "sandbox_backend": "state", @@ -1598,6 +1646,18 @@ def step_antv_charts() -> None: else: state["enable_antv_charts"] = prompt_antv_charts() + def step_code_execution() -> None: + if state["ai_framework"] == AIFrameworkType.PYDANTIC_AI: + state["enable_code_execution"] = prompt_code_execution() + else: + state["enable_code_execution"] = False + + def step_skills() -> None: + if state["ai_framework"] == AIFrameworkType.PYDANTIC_AI: + state["enable_skills"] = prompt_skills() + else: + state["enable_skills"] = False + def step_langsmith() -> None: if state["ai_framework"] in ( AIFrameworkType.LANGCHAIN, @@ -1669,6 +1729,8 @@ def step_marketing() -> None: ("RAG", step_rag_config), ("Chart Tool", step_charts), ("AntV Diagrams & Maps", step_antv_charts), + ("Code Execution", step_code_execution), + ("Skills System", step_skills), ("LangSmith", step_langsmith), ("Messaging Channels", step_channels), ("Teams & Billing", step_teams_billing), @@ -1716,6 +1778,8 @@ def step_marketing() -> None: enable_web_fetch = state["enable_web_fetch"] enable_charts = state["enable_charts"] enable_antv_charts = state["enable_antv_charts"] + enable_code_execution = state["enable_code_execution"] + enable_skills = state["enable_skills"] rag_features = state["rag_features"] enable_langsmith = state["enable_langsmith"] use_telegram = state["use_telegram"] @@ -1759,6 +1823,8 @@ def step_marketing() -> None: enable_web_fetch=enable_web_fetch, enable_charts=enable_charts, enable_antv_charts=enable_antv_charts, + enable_code_execution=enable_code_execution, + enable_skills=enable_skills, use_telegram=use_telegram, use_slack=use_slack, rate_limit_requests=rate_limit_requests, diff --git a/template/VARIABLES.md b/template/VARIABLES.md index bbd1b6df..85908a10 100644 --- a/template/VARIABLES.md +++ b/template/VARIABLES.md @@ -287,6 +287,8 @@ These variables are set automatically by the generator. | `enable_charts` | bool | `false` | Enable the chart-generation tool (line/bar/pie/area/scatter); interactive in web chat, PNG on Slack/Telegram | Requires an AI framework | | `charts_channel_png` | bool | `false` | Computed: render charts to PNG for messaging channels | `enable_charts` and (`use_slack` or `use_telegram`) | | `enable_antv_charts` | bool | `false` | Adds the interactive `create_map` tool (Leaflet/OpenStreetMap, works out of the box) plus AntV advanced-diagram tools (flowchart, mind-map, org-chart, sankey, ...) via an `mcp-server-chart` Docker sidecar. Wired into all 5 frameworks. The AntV diagrams need the sidecar running (`docker compose --profile antv up -d`) and `ENABLE_ANTV_CHARTS=true` at runtime; maps work without it. Present in the production compose but profile-gated — off by default and publishes no host port, so it's opt-in per environment; for prod, self-host GPT-Vis-SSR via `ANTV_VIS_REQUEST_SERVER` rather than AntV's public render backend | Requires an AI framework | +| `enable_code_execution` | bool | `false` | Adds a `run_python` tool backed by the Monty sandboxed Python interpreter (`pydantic-monty`). The model can compute projections, run aggregations, and call `create_chart`/`create_map` from inside the sandbox — charts render as interactive Recharts cards immediately. Sandbox allows `math`, `asyncio`, `json`, `datetime`, `re`; no `statistics`, `random`, `itertools`, numpy/pandas. Activated at runtime with `ENABLE_CODE_EXECUTION=true`. **PydanticAI only.** Temporary shim — swap to the official `CodeExecutionToolset` when it ships in pydantic-ai | Requires `use_pydantic_ai` | +| `enable_skills` | bool | `false` | Adds a `pydantic-ai-skills` `SkillsToolset` that loads `SKILL.md` files from `backend/skills/` as agent tools (the model picks a skill, then follows its instructions). Ships the loader only — drop your own skills into `backend/skills/`; the toolset no-ops when the directory is empty. Pairs well with `enable_code_execution` for skills that compute. **PydanticAI only.** | Requires `use_pydantic_ai` | **Notes:** diff --git a/template/cookiecutter.json b/template/cookiecutter.json index c9edd29d..c09fee88 100644 --- a/template/cookiecutter.json +++ b/template/cookiecutter.json @@ -92,6 +92,8 @@ "enable_charts": false, "charts_channel_png": false, "enable_antv_charts": false, + "enable_code_execution": false, + "enable_skills": false, "enable_webhooks": false, "websocket_auth": "jwt", "websocket_auth_jwt": true, diff --git a/template/hooks/post_gen_project.py b/template/hooks/post_gen_project.py index 5eb47003..7e820bf7 100644 --- a/template/hooks/post_gen_project.py +++ b/template/hooks/post_gen_project.py @@ -49,6 +49,7 @@ enable_charts = "{{ cookiecutter.enable_charts }}" == "True" charts_channel_png = "{{ cookiecutter.charts_channel_png }}" == "True" enable_antv_charts = "{{ cookiecutter.enable_antv_charts }}" == "True" +enable_code_execution = "{{ cookiecutter.enable_code_execution }}" == "True" use_pydantic_deep = "{{ cookiecutter.use_pydantic_deep }}" == "True" use_telegram = "{{ cookiecutter.use_telegram }}" == "True" use_slack = "{{ cookiecutter.use_slack }}" == "True" @@ -118,6 +119,7 @@ def remove_dir(path: str) -> None: # --- AI Agent files (remove unused framework-specific files) --- if not use_pydantic_ai: remove_file(os.path.join(backend_app, "agents", "assistant.py")) + remove_file(os.path.join(backend_app, "agents", "tools", "ask_user_tool.py")) if not use_langchain: remove_file(os.path.join(backend_app, "agents", "langchain_assistant.py")) if not use_langgraph: @@ -152,6 +154,8 @@ def remove_dir(path: str) -> None: frontend_src = os.path.join(os.getcwd(), "frontend", "src") remove_file(os.path.join(frontend_src, "components", "chat", "map-leaflet.tsx")) remove_file(os.path.join(frontend_src, "components", "chat", "map-message.tsx")) +if not enable_code_execution: + remove_file(os.path.join(backend_app, "agents", "tools", "code_execution.py")) # --- No-AI mode: remove all AI/chat/conversation files --- if not use_ai: diff --git a/template/{{cookiecutter.project_slug}}/backend/.env.example b/template/{{cookiecutter.project_slug}}/backend/.env.example index 0fc8c674..302e68f6 100644 --- a/template/{{cookiecutter.project_slug}}/backend/.env.example +++ b/template/{{cookiecutter.project_slug}}/backend/.env.example @@ -252,6 +252,13 @@ ANTV_VIS_REQUEST_SERVER= # default (drops basic charts that overlap create_chart, and China-only maps). ANTV_DISABLED_TOOLS= {%- endif %} +{%- if cookiecutter.enable_code_execution %} + +# === Code execution (Monty sandbox) === +ENABLE_CODE_EXECUTION=true +CODE_EXECUTION_TIMEOUT_SECS=10 +CODE_EXECUTION_MAX_ALLOCATIONS=50000000 +{%- endif %} {%- if cookiecutter.enable_langsmith %} # === LangSmith Observability === diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py index 118887df..b09de32c 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/assistant.py @@ -5,10 +5,11 @@ """ import logging +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from typing import Any -from pydantic_ai import Agent, ModelRetry{%- if cookiecutter.enable_rag %}, RunContext{%- endif %} +from pydantic_ai import Agent{%- if cookiecutter.enable_rag %}, ModelRetry{%- endif %}, RunContext from pydantic_ai.capabilities import ( ReinjectSystemPrompt, Thinking, @@ -48,6 +49,7 @@ from app.agents.prompts import get_system_prompt_with_rag {%- endif %} from app.agents.tools import get_current_datetime +from app.agents.tools.ask_user_tool import MAX_QUESTIONS, QuestionItem, format_answers {%- if cookiecutter.enable_rag %} from app.agents.tools.rag_tool import search_knowledge_base {%- endif %} @@ -58,6 +60,15 @@ from app.agents.tools.antv_chart import get_antv_toolset from app.agents.tools.map_tool import MapMarker, create_map {%- endif %} +{%- if cookiecutter.enable_code_execution %} +from app.agents.tools.code_execution import EmitToolEvent +from app.agents.tools.code_execution import run_python as run_python_code +{%- endif %} +{%- if cookiecutter.enable_skills %} +from pathlib import Path + +from pydantic_ai_skills import SkillsToolset +{%- endif %} from app.core.config import settings logger = logging.getLogger(__name__) @@ -135,6 +146,9 @@ def _build_model(model_name: str): {%- endif %} +AskUserCallback = Callable[[list[dict[str, Any]]], Awaitable[list[dict[str, Any]]]] + + @dataclass class Deps: """Dependencies for the assistant agent. @@ -149,6 +163,10 @@ class Deps: kb_collection_names: list[str] = field(default_factory=list) {%- endif %} metadata: dict[str, Any] = field(default_factory=dict) + ask_user: AskUserCallback | None = None +{%- if cookiecutter.enable_code_execution %} + emit_tool_event: EmitToolEvent | None = None +{%- endif %} class AssistantAgent: @@ -212,6 +230,16 @@ def _create_agent(self) -> Agent[Deps, str]: # None when AntV is disabled or the sidecar is unavailable. antv_toolset = get_antv_toolset() toolsets = [antv_toolset] if antv_toolset is not None else [] +{%- else %} +{%- if cookiecutter.enable_skills %} + toolsets: list = [] +{%- endif %} +{%- endif %} +{%- if cookiecutter.enable_skills %} + + skills_dir = Path(__file__).parent.parent.parent / "skills" + if skills_dir.exists(): + toolsets.append(SkillsToolset(directories=[str(skills_dir)])) {%- endif %} agent = Agent[Deps, str]( @@ -219,7 +247,7 @@ def _create_agent(self) -> Agent[Deps, str]: model_settings=model_settings, system_prompt=self.system_prompt, capabilities=capabilities, -{%- if cookiecutter.enable_antv_charts %} +{%- if cookiecutter.enable_antv_charts or cookiecutter.enable_skills %} toolsets=toolsets, {%- endif %} ) @@ -337,6 +365,82 @@ def create_map_tool( ) {%- endif %} + @agent.tool + async def ask_user(ctx: RunContext[Deps], questions: list[QuestionItem]) -> str: + """Ask the user one or more questions and wait for their answers. + + Use this when a decision or missing detail would materially change what + you do next and you can't reasonably assume it. You may pass several + questions at once — the user answers them one after another and you get + all the answers back together (good for an intake/setup flow). You can + also call this again later to follow up on what they said. Prefer + answering directly when the request is already clear. + + Args: + questions: The questions to ask. Each has the question text, optional + suggested `options`, and `allow_custom` (whether a free-form + answer is allowed, default True). + + Returns: + The user's answers as a Q/A transcript, with skipped questions marked. + """ + if ctx.deps.ask_user is None: + return ( + "User interaction is unavailable here; proceed with a reasonable " + "assumption and state it briefly." + ) + if not questions: + return "No questions were provided." + payload = [q.model_dump() for q in questions[:MAX_QUESTIONS]] + answers = await ctx.deps.ask_user(payload) + return format_answers(payload, answers) + +{%- if cookiecutter.enable_code_execution %} + + if settings.ENABLE_CODE_EXECUTION: + + @agent.tool + async def run_python(ctx: RunContext[Deps], code: str) -> str: + """Run Python in a sandbox to compute and to build visualizations. + + Use this for multi-step number-crunching (projections, aggregations, + simulations) and whenever you want to produce several charts at once. +{%- if cookiecutter.enable_charts or cookiecutter.enable_antv_charts %} + Inside the code you can call: + - ``create_chart(chart_type, title, data, series=None, x_key="x", style=None)`` +{%- if cookiecutter.enable_antv_charts %} + - ``create_map(title, markers, center=None, zoom=None)`` +{%- endif %} + - ``current_datetime()`` + ``create_chart``{%- if cookiecutter.enable_antv_charts %}/``create_map``{%- endif %} are async — call them with ``await``, + and run several in parallel with ``await asyncio.gather(...)``. Each one + renders to the user immediately as an interactive chart/map. +{%- else %} + Inside the code you can call ``current_datetime()``. +{%- endif %} + SANDBOX LIMITATIONS — violating these causes "Execution failed" errors: + - NO comma thousands separator in f-strings: ``{x:,}`` or ``{x:,.2f}`` + CRASHES. Use ``f"${int(x)}"`` or ``f"{x:.2f}"`` instead. + - NO ``statistics``, ``random``, ``itertools``, ``collections``, + numpy, pandas — compute stats manually with loops/math. + - NO file I/O, network calls, or OS access. + - NO ``import`` of any module not in: math, asyncio, json, datetime, re. + - Walrus operator ``:=`` is unsupported. + - f-string expressions must be simple: no ``!r``, no ``=`` suffix + (``{x=}`` debug format crashes). Use ``print(f"x = {x}")``. + + Print intermediate values you want to keep; don't paste the returned + chart JSON back to the user. + + Args: + code: The Python source to execute. + + Returns: + The captured stdout plus the final expression value, or an error + message you can read and fix. + """ + return await run_python_code(code, emit=ctx.deps.emit_tool_event) +{%- endif %} @staticmethod def _build_model_history( diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/prompts.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/prompts.py index 9baa95d4..59605698 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/agents/prompts.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/prompts.py @@ -23,6 +23,22 @@ # Output Let formatting serve comprehension. Default to clear plain paragraphs for explanations and discussion. Reach for headers, bullets, or numbered lists only when they genuinely make the answer easier to scan — steps, comparisons, or rankings — or when the user asks for them. Honor explicit formatting and length preferences from the user. Lead with the conclusion, then the supporting detail, then any caveats.""" +{%- if cookiecutter.use_pydantic_ai %} + +DEFAULT_SYSTEM_PROMPT += """ + +# Asking the user +You have an `ask_user` tool that puts questions to the user and waits for their +answers before you continue. Reach for it only when a decision or missing detail +would genuinely change what you do next and you can't reasonably assume it — not +for things you can decide yourself. The tool takes a list of questions: pass +several at once when you need to gather a few things up front (an intake/setup +flow), and the user will answer them one after another. You can also call it +again later to follow up on what they said. Give each question a few short +`options` when there are natural choices, and leave `allow_custom` on so the user +can answer in their own words. If the user skips, proceed with a sensible default +and say briefly what you assumed.""" +{%- endif %} {%- if cookiecutter.enable_charts %} DEFAULT_SYSTEM_PROMPT += """ @@ -89,6 +105,45 @@ def _antv_guidance() -> str: DEFAULT_SYSTEM_PROMPT += _antv_guidance() {%- endif %} +{%- if cookiecutter.enable_code_execution %} + + +CODE_EXECUTION_GUIDANCE = """ + +# Running code +You have a `run_python` tool that executes Python in a sandbox. Reach for it when +a task needs real computation — projections, aggregations, simulations, parsing a +table the user pasted — or when you want to produce several charts at once. +{%- if cookiecutter.enable_charts or cookiecutter.enable_antv_charts %} + +Inside the code you can call `create_chart(...)`{%- if cookiecutter.enable_antv_charts %}, `create_map(...)`{%- endif %} and +`current_datetime()` directly. `create_chart`{%- if cookiecutter.enable_antv_charts %} and `create_map`{%- endif %} are async: call +them with `await`, and fire several in parallel with +`await asyncio.gather(create_chart(...), create_chart(...), ...)`. Each call +renders to the user immediately as an interactive chart/map, just like calling the +tool yourself — so don't separately call `create_chart` for the same data after +the code runs, and don't paste the returned JSON back to the user. +{%- endif %} + +The sandbox is a restricted Python subset: `math`, `asyncio`, `json`, `datetime` +and `re` import fine, but many modules (`statistics`, `random`, `itertools`, +`collections`, `functools`, numpy/pandas) are NOT available — compute means, +sums, and groupings yourself with plain loops and comprehensions. There is no +file, network, or OS access. The f-string `,` thousands separator isn't +supported (write `f"{x:.2f}"`, not `f"{x:,.2f}"`). `print(...)` the intermediate +numbers you want to reason about afterwards. Keep each block focused, then +briefly explain the results and charts in plain language.""" + + +def _code_execution_guidance() -> str: + """run_python guidance — included only when code execution is enabled.""" + from app.core.config import settings + + return CODE_EXECUTION_GUIDANCE if settings.ENABLE_CODE_EXECUTION else "" + + +DEFAULT_SYSTEM_PROMPT += _code_execution_guidance() +{%- endif %} def get_system_prompt_with_rag() -> str: diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/ask_user_tool.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/ask_user_tool.py new file mode 100644 index 00000000..b76751b2 --- /dev/null +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/ask_user_tool.py @@ -0,0 +1,39 @@ +"""Ask-the-user tool helpers. + +The ``ask_user`` tool lets the agent pause a run and put one or more questions to +the user, then resume with their answers. The actual pause/resume lives in the +WebSocket session (it owns the socket); this module just defines the question +schema and formats the collected answers into a result for the model. +""" + +from typing import Any + +from pydantic import BaseModel, Field + +MAX_QUESTIONS = 10 + + +class QuestionItem(BaseModel): + """One question to put to the user.""" + + question: str = Field(description="The question text.") + options: list[str] = Field( + default_factory=list, + description="Optional suggested answers, shown as numbered choices.", + ) + allow_custom: bool = Field( + default=True, + description="Whether the user may type a free-form answer instead of picking an option.", + ) + + +def format_answers(questions: list[dict[str, Any]], answers: list[dict[str, Any]]) -> str: + """Render the collected answers as a readable Q/A transcript for the model.""" + lines: list[str] = [] + for i, q in enumerate(questions): + a = answers[i] if i < len(answers) else {} + if not isinstance(a, dict): + a = {} + ans = "(skipped)" if a.get("skipped") else str(a.get("answer", "")).strip() or "(no answer)" + lines.append(f"Q: {q.get('question', '')}\nA: {ans}") + return "\n\n".join(lines) diff --git a/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/code_execution.py b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/code_execution.py new file mode 100644 index 00000000..8b36cd81 --- /dev/null +++ b/template/{{cookiecutter.project_slug}}/backend/app/agents/tools/code_execution.py @@ -0,0 +1,294 @@ +{%- if cookiecutter.enable_code_execution %} +"""Code-execution tool backed by the Monty sandbox. + +Runs model-written Python inside the Monty sandboxed interpreter, exposing a +curated set of our own tools as plain in-sandbox functions. In one tool turn +the model can compute, loop, transform data, and call those tools — including +several ``create_chart`` calls in a single ``asyncio.gather(...)`` block. + +Visualizations produced from *inside* the executed code are surfaced to the +live session through an ``emit_tool_event`` callback, so each one renders as +an interactive chart/map card (and is persisted) exactly like a direct tool +call — rather than being buried inside the ``run_python`` result. + +This is a thin local stand-in for PydanticAI's forthcoming +``CodeExecutionToolset``; swap to the official class once it ships. +""" + +import json +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +from pydantic_monty import CollectString, Monty, MontyError, ResourceLimits + +{%- if cookiecutter.enable_charts or cookiecutter.enable_antv_charts %} +from app.agents.tools.chart_tool import create_chart +{%- endif %} +from app.agents.tools.datetime_tool import get_current_datetime +{%- if cookiecutter.enable_antv_charts %} +from app.agents.tools.map_tool import create_map +{%- endif %} +from app.core.config import settings + +logger = logging.getLogger(__name__) + +EmitToolEvent = Callable[[str, dict[str, Any], str], Awaitable[None]] + +MAX_OUTPUT_CHARS = 8000 +{%- if cookiecutter.enable_antv_charts %} + + +async def _call_antv(tool_name: str, args: dict[str, Any]) -> str: + """Call an AntV ``mcp-server-chart`` tool over MCP and return the image URL. + + The sidecar renders server-side and returns a bare URL string. We open a + short-lived streamable-HTTP session per call — fine for the handful of + charts a single run produces. + """ + if not settings.ENABLE_ANTV_CHARTS: + return ( + "AntV charts are disabled (ENABLE_ANTV_CHARTS=false). " + "Skip the generate_* chart and rely on create_chart instead." + ) + try: + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + + async with ( + streamablehttp_client(settings.ANTV_MCP_URL) as (read, write, _), + ClientSession(read, write) as session, + ): + await session.initialize() + result = await session.call_tool(tool_name, args) + if result.isError: + detail = result.content[0].text if result.content else "unknown error" # type: ignore[union-attr] + return f"Chart generation failed ({tool_name}): {detail}" + for item in result.content: + text = getattr(item, "text", None) + if text: + return text.strip() + return f"Chart generation returned no image ({tool_name})." + except Exception as exc: # noqa: BLE001 — surface a readable message to the model + logger.warning("AntV call %s failed: %s", tool_name, exc) + return f"Chart generation failed ({tool_name}): {exc}" +{%- endif %} + + +def _build_external_functions(emit: EmitToolEvent | None) -> dict[str, Callable[..., Any]]: + """Build the callables exposed to sandboxed code, wired to emit live events.""" + + functions: dict[str, Callable[..., Any]] = {} + +{%- if cookiecutter.enable_charts or cookiecutter.enable_antv_charts %} + + async def _create_chart( + chart_type: str, + title: str, + data: list[dict[str, Any]], + series: list[dict[str, Any]] | None = None, + x_key: str = "x", + style: dict[str, Any] | None = None, + ) -> str: + spec = create_chart( + chart_type=chart_type, # type: ignore[arg-type] + title=title, + data=data, + series=series, + x_key=x_key, + style=style, + ) + if emit is not None: + await emit( + "create_chart_tool", + { + "chart_type": chart_type, + "title": title, + "data": data, + "series": series, + "x_key": x_key, + "style": style, + }, + spec, + ) + return spec + + functions["create_chart"] = _create_chart +{%- endif %} + +{%- if cookiecutter.enable_antv_charts %} + + async def _create_map( + title: str, + markers: list[dict[str, Any]], + center: list[float] | None = None, + zoom: int | None = None, + ) -> str: + spec = create_map(title=title, markers=markers, center=center, zoom=zoom) + if emit is not None: + await emit( + "create_map_tool", + {"title": title, "markers": markers, "center": center, "zoom": zoom}, + spec, + ) + return spec + + functions["create_map"] = _create_map +{%- endif %} + + def _current_datetime() -> dict[str, str]: + return get_current_datetime() + + functions["current_datetime"] = _current_datetime + +{%- if cookiecutter.enable_antv_charts %} + + async def _emit_antv(tool_name: str, args: dict[str, Any]) -> str: + """Run an AntV chart tool and stream it to the live session like a real + tool call, so it renders as a chart card and gets persisted.""" + # Drop None values so the sidecar applies its own defaults. + clean = {k: v for k, v in args.items() if v is not None and v != ""} + url = await _call_antv(tool_name, clean) + if emit is not None and url.startswith("http"): + await emit(tool_name, clean, url) + return url + + async def _gen_waterfall( + title: str, + data: list[dict[str, Any]], + axisXTitle: str = "", + axisYTitle: str = "", + ) -> str: + return await _emit_antv( + "generate_waterfall_chart", + {"title": title, "data": data, "axisXTitle": axisXTitle, "axisYTitle": axisYTitle}, + ) + + async def _gen_sankey( + title: str, + data: list[dict[str, Any]], + nodeAlign: str = "center", + ) -> str: + return await _emit_antv( + "generate_sankey_chart", + {"title": title, "data": data, "nodeAlign": nodeAlign}, + ) + + async def _gen_funnel(title: str, data: list[dict[str, Any]]) -> str: + return await _emit_antv("generate_funnel_chart", {"title": title, "data": data}) + + async def _gen_treemap(title: str, data: list[dict[str, Any]]) -> str: + return await _emit_antv("generate_treemap_chart", {"title": title, "data": data}) + + async def _gen_radar(title: str, data: list[dict[str, Any]]) -> str: + return await _emit_antv("generate_radar_chart", {"title": title, "data": data}) + + async def _gen_histogram( + title: str, + data: list[Any], + binNumber: int | None = None, + axisXTitle: str = "", + axisYTitle: str = "", + ) -> str: + return await _emit_antv( + "generate_histogram_chart", + { + "title": title, + "data": data, + "binNumber": binNumber, + "axisXTitle": axisXTitle, + "axisYTitle": axisYTitle, + }, + ) + + async def _gen_boxplot( + title: str, + data: list[dict[str, Any]], + axisXTitle: str = "", + axisYTitle: str = "", + ) -> str: + return await _emit_antv( + "generate_boxplot_chart", + {"title": title, "data": data, "axisXTitle": axisXTitle, "axisYTitle": axisYTitle}, + ) + + async def _gen_dual_axes( + title: str, + categories: list[Any], + series: list[dict[str, Any]], + axisXTitle: str = "", + ) -> str: + return await _emit_antv( + "generate_dual_axes_chart", + {"title": title, "categories": categories, "series": series, "axisXTitle": axisXTitle}, + ) + + functions["generate_waterfall_chart"] = _gen_waterfall + functions["generate_sankey_chart"] = _gen_sankey + functions["generate_funnel_chart"] = _gen_funnel + functions["generate_treemap_chart"] = _gen_treemap + functions["generate_radar_chart"] = _gen_radar + functions["generate_histogram_chart"] = _gen_histogram + functions["generate_boxplot_chart"] = _gen_boxplot + functions["generate_dual_axes_chart"] = _gen_dual_axes +{%- endif %} + + return functions + + +def _clip(text: str) -> str: + """Cap text handed back to the model so a huge result/error can't blow up the turn.""" + if len(text) > MAX_OUTPUT_CHARS: + return text[:MAX_OUTPUT_CHARS] + "\n…(output truncated)" + return text + + +def _format_result(stdout: str, output: Any) -> str: + """Combine captured stdout and the final expression value for the model.""" + parts: list[str] = [] + if stdout.strip(): + parts.append(f"stdout:\n{stdout.rstrip()}") + if output is not None: + try: + rendered = json.dumps(output, default=str) + except (TypeError, ValueError): + rendered = str(output) + parts.append(f"result: {rendered}") + text = "\n\n".join(parts) if parts else "(code ran successfully with no output)" + return _clip(text) + + +async def run_python(code: str, *, emit: EmitToolEvent | None = None) -> str: + """Execute model-written Python in the Monty sandbox and return its output. + + Args: + code: The Python source to run. A restricted stdlib subset (``math``, + ``asyncio``, ``json``, ``datetime``, ``re``) works, but modules like + ``statistics``/``random``/``itertools`` are unavailable. + emit: Optional callback used to stream visualizations created inside the + code to the live session. + + Returns: + The captured stdout plus the value of the final expression, or an error + message the model can read and recover from. + """ + limits: ResourceLimits = { + "max_duration_secs": settings.CODE_EXECUTION_TIMEOUT_SECS, + "max_allocations": settings.CODE_EXECUTION_MAX_ALLOCATIONS, + } + collector = CollectString() + try: + monty = await Monty.acreate(code) + output = await monty.run_async( + external_functions=_build_external_functions(emit), + print_callback=collector, + limits=limits, + ) + except MontyError as e: + return _clip(f"Execution failed: {e}") + except Exception as e: + logger.exception("run_python execution failed") + return _clip(f"Execution failed: {e}") + + return _format_result(collector.output, output) +{%- endif %} diff --git a/template/{{cookiecutter.project_slug}}/backend/app/core/config.py b/template/{{cookiecutter.project_slug}}/backend/app/core/config.py index 5227905f..5cadcc45 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/core/config.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/core/config.py @@ -436,6 +436,12 @@ def REDIS_URL(self) -> str: # the basic charts that overlap create_chart and the China-only maps. ANTV_DISABLED_TOOLS: str = "" {%- endif %} +{%- if cookiecutter.enable_code_execution %} + + ENABLE_CODE_EXECUTION: bool = False + CODE_EXECUTION_TIMEOUT_SECS: float = 10.0 + CODE_EXECUTION_MAX_ALLOCATIONS: int = 50_000_000 +{%- endif %} {%- if cookiecutter.use_deepagents %} # === DeepAgents Configuration === diff --git a/template/{{cookiecutter.project_slug}}/backend/app/services/agent_session.py b/template/{{cookiecutter.project_slug}}/backend/app/services/agent_session.py index fad88e91..121dc9d1 100644 --- a/template/{{cookiecutter.project_slug}}/backend/app/services/agent_session.py +++ b/template/{{cookiecutter.project_slug}}/backend/app/services/agent_session.py @@ -10,8 +10,14 @@ ``AgentSession.process_message``. """ +{%- if cookiecutter.enable_code_execution %} +import asyncio +{%- endif %} import logging from typing import Any +{%- if cookiecutter.enable_code_execution %} +from uuid import uuid4 +{%- endif %} from fastapi import WebSocket, WebSocketDisconnect from pydantic_ai import ( @@ -70,12 +76,21 @@ def __init__( {%- endif %} self.conversation_history: list[dict[str, str]] = [] self.deps = Deps() + self.deps.ask_user = self._ask_user +{%- if cookiecutter.enable_code_execution %} + self.deps.emit_tool_event = self._emit_tool_event + self._current_tool_calls: list[dict[str, Any]] | None = None + self._emit_lock = asyncio.Lock() +{%- endif %} {%- if cookiecutter.use_database %} self.current_conversation_id: str | None = None {%- endif %} async def process_message(self, data: dict[str, Any]) -> None: """Process one user turn: persist input, run the agent, stream events, persist output.""" + if data.get("type") == "ask_user_response": + return + user_message = data.get("message", "") file_ids = data.get("file_ids", []) @@ -140,10 +155,21 @@ async def process_message(self, data: dict[str, Any]) -> None: {%- endif %} collected_tool_calls: list[dict[str, Any]] = [] +{%- if cookiecutter.enable_code_execution %} + self._current_tool_calls = collected_tool_calls + try: + async with assistant.agent.iter( + user_input, deps=self.deps, message_history=model_history + ) as agent_run: + await self._stream_agent_run(agent_run, user_message, collected_tool_calls) + finally: + self._current_tool_calls = None +{%- else %} async with assistant.agent.iter( user_input, deps=self.deps, message_history=model_history ) as agent_run: await self._stream_agent_run(agent_run, user_message, collected_tool_calls) +{%- endif %} # Update in-memory history only after a complete agent run if agent_run.result is not None: @@ -196,6 +222,59 @@ async def process_message(self, data: dict[str, Any]) -> None: logger.exception(f"Error processing agent request: {e}") await send_event(self.websocket, "error", {"message": str(e)}) + async def _ask_user(self, questions: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Pause the run: ask the client questions and block until they answer. + + Emits an ``ask_user`` event with the whole batch, then reads frames off + this socket until an ``ask_user_response`` arrives. This is safe even + though the route also reads from the socket: while a tool runs, the agent + run (and therefore the route's receive loop) is suspended awaiting us, so + there is exactly one active reader. The client returns a list of answers + parallel to the questions ({answer, skipped}). + """ + await send_event(self.websocket, "ask_user", {"questions": questions}) + while True: + data = await self.websocket.receive_json() + if data.get("type") == "ask_user_response": + answers = data.get("answers") + return answers if isinstance(answers, list) else [] + # Ignore unrelated frames while questions are pending (UI is modal). + +{%- if cookiecutter.enable_code_execution %} + + async def _emit_tool_event( + self, tool_name: str, args: dict[str, Any], result: str + ) -> None: + """Surface a tool call made from *inside* the run_python sandbox. + + Emits the same ``tool_call`` + ``tool_result`` event pair the streaming + loop sends for normal tool calls, so an in-code ``create_chart`` renders + as an interactive card, and records it in the in-flight turn's tool-call + list so it is persisted with the message. + """ + tool_call_id = f"code-{uuid4().hex}" + async with self._emit_lock: + if self._current_tool_calls is not None: + self._current_tool_calls.append( + { + "tool_call_id": tool_call_id, + "tool_name": tool_name, + "args": args, + "result": result, + } + ) + await send_event( + self.websocket, + "tool_call", + {"tool_call_id": tool_call_id, "tool_name": tool_name, "args": args}, + ) + await send_event( + self.websocket, + "tool_result", + {"tool_call_id": tool_call_id, "content": result}, + ) +{%- endif %} + {%- if cookiecutter.enable_billing and cookiecutter.enable_teams and cookiecutter.enable_credits_system and (cookiecutter.use_postgresql or cookiecutter.use_sqlite) %} async def _record_usage( self, @@ -397,7 +476,7 @@ async def _stream_tool_events( tc = { "tool_call_id": tool_event.part.tool_call_id, "tool_name": tool_event.part.tool_name, - "args": tool_event.part.args, + "args": tool_event.part.args_as_dict(raise_if_invalid=False), } collected_tool_calls.append(tc) pending[tool_event.part.tool_call_id] = tc @@ -2465,7 +2544,7 @@ async def _stream_tool_events( tc = { "tool_call_id": tool_event.part.tool_call_id, "tool_name": tool_event.part.tool_name, - "args": tool_event.part.args, + "args": tool_event.part.args_as_dict(raise_if_invalid=False), } collected_tool_calls.append(tc) pending[tool_event.part.tool_call_id] = tc diff --git a/template/{{cookiecutter.project_slug}}/backend/pyproject.toml b/template/{{cookiecutter.project_slug}}/backend/pyproject.toml index 2d204f0a..d02b6e8b 100644 --- a/template/{{cookiecutter.project_slug}}/backend/pyproject.toml +++ b/template/{{cookiecutter.project_slug}}/backend/pyproject.toml @@ -288,6 +288,12 @@ dependencies = [ "crewai-tools[mcp]>=1.0.0", {%- endif %} {%- endif %} +{%- if cookiecutter.enable_code_execution %} + "pydantic-monty>=0.0.18", +{%- endif %} +{%- if cookiecutter.enable_skills %} + "pydantic-ai-skills>=0.11.0", +{%- endif %} {%- if cookiecutter.use_telegram or cookiecutter.use_slack %} "cryptography>=44.0.0", {%- endif %} diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/chat-container.tsx b/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/chat-container.tsx index 7580a711..de8986e0 100644 --- a/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/chat-container.tsx +++ b/template/{{cookiecutter.project_slug}}/frontend/src/components/chat/chat-container.tsx @@ -9,7 +9,8 @@ import { FilePreviewPanel } from "./file-preview-panel"; import { MessageList } from "./message-list"; import { PendingMessages } from "./pending-messages"; import { ToolApprovalDialog } from "./tool-approval-dialog"; -import type { PendingApproval, Decision } from "@/types"; +import { QuestionPrompt } from "@/components/ui"; +import type { PendingApproval, AskUserQuestion, AskUserAnswer, Decision } from "@/types"; import { useConversationStore, useChatStore } from "@/stores"; import { useConversations } from "@/hooks"; {%- if cookiecutter.use_auth %} @@ -50,6 +51,8 @@ function AuthenticatedChatContainer() { setThinkingEffort, pendingApproval, sendResumeDecisions, + pendingQuestions, + sendAskUserResponses, } = useChat({ conversationId: currentConversationId, onConversationCreated: handleConversationCreated, @@ -238,6 +241,8 @@ function AuthenticatedChatContainer() { scrollContainerRef={scrollContainerRef} pendingApproval={pendingApproval} onResumeDecisions={sendResumeDecisions} + pendingQuestions={pendingQuestions} + onAnswerQuestions={sendAskUserResponses} /> ); } @@ -265,6 +270,8 @@ interface ChatUIProps { scrollContainerRef: React.RefObject; pendingApproval?: PendingApproval | null; onResumeDecisions?: (decisions: Decision[]) => void; + pendingQuestions?: AskUserQuestion[] | null; + onAnswerQuestions?: (answers: AskUserAnswer[]) => void; } function ChatUI({ @@ -285,6 +292,8 @@ function ChatUI({ scrollContainerRef, pendingApproval, onResumeDecisions, + pendingQuestions, + onAnswerQuestions, }: ChatUIProps) { return (
@@ -317,6 +326,17 @@ function ChatUI({
)} + {/* ask_user: interactive question card while the run is paused */} + {pendingQuestions && pendingQuestions.length > 0 && onAnswerQuestions && ( +
+ +
+ )} +
{queuedMessages && queuedMessages.length > 0 && onCancelQueued && ( @@ -325,7 +345,11 @@ function ChatUI({
w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); } +{%- endif %} + +{%- if cookiecutter.enable_skills %} +/** "market_data" -> "Market Data", "fire" -> "Fire". */ +function formatSkillName(name: string): string { + return name + .split("_") + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + +/** Extract the description text from a `load_skill` XML result. + * The library returns . */ +function parseLoadSkillResult(result: string): { description: string } | null { + const m = result.match(/([\s\S]*?)<\/description>/); + if (!m) return null; + return { description: m[1].trim() }; +} + +/** Clean card for a loaded skill — just the description, no raw XML. */ +function LoadSkillResult({ resultText, status }: { resultText: string; status: string }) { + if (!resultText || status !== "completed") { + return ( +

+ {status === "error" ? "Failed to load skill." : "Loading…"} +

+ ); + } + const parsed = parseLoadSkillResult(resultText); + if (!parsed) return null; + return ( +

{parsed.description}

+ ); +} +{%- endif %} + +{%- if cookiecutter.enable_antv_charts %} /** Render a server-rendered AntV diagram image with an "open full size" link. */ function AntvChartImage({ url, title }: { url: string; title: string }) { return ( @@ -542,6 +581,60 @@ function AntvChartImage({ url, title }: { url: string; title: string }) { } {%- endif %} +/** Pull the question texts out of an `ask_user` tool's args (object or + * JSON-string). Handles the `questions` list. Returns [] when none found. */ +function extractQuestions(args: unknown): string[] { + let obj: unknown = args; + if (typeof args === "string") { + try { + obj = JSON.parse(args); + } catch { + return []; + } + } + if (obj && typeof obj === "object" && Array.isArray((obj as { questions?: unknown }).questions)) { + return (obj as { questions: Array<{ question?: unknown }> }).questions.map((q) => + String(q?.question ?? ""), + ); + } + return []; +} + +/** Transcript view of an `ask_user` turn. Once answered, the result is already a + * "Q: …/A: …" transcript, so render it as-is; while waiting, list the + * questions that were asked. */ +function AskUserResult({ args, resultText }: { args: unknown; resultText: string }) { + if (resultText) { + return ( +

+ {resultText} +

+ ); + } + const questions = extractQuestions(args); + return ( +
+
+

+ {questions.length > 1 ? "Questions" : "Question"} +

+ {questions.length > 0 ? ( +
    + {questions.map((q, i) => ( +
  • {q}
  • + ))} +
+ ) : ( +

Waiting for the user…

+ )} +
+ {questions.length > 0 && ( +

Waiting for the user…

+ )} +
+ ); +} + // --- Main component --- export function ToolCallCard({ toolCall }: ToolCallCardProps) { @@ -549,25 +642,26 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { // formatted view for args + raw output (the button). Charts are the // exception: they're only useful when visible, so expand them by default. const [expanded, setExpanded] = useState( + toolCall.name === "ask_user" || {%- if cookiecutter.enable_charts and cookiecutter.enable_antv_charts %} - toolCall.status === "completed" && - ((toolCall.name === "create_chart_tool" && parseChartResult(toolCall.result) !== null) || - (toolCall.name === "create_map_tool" && parseMapResult(toolCall.result) !== null) || - (toolCall.name.startsWith("generate_") && - typeof toolCall.result === "string" && - parseAntvImageUrl(toolCall.result) !== null)), + (toolCall.status === "completed" && + ((toolCall.name === "create_chart_tool" && parseChartResult(toolCall.result) !== null) || + (toolCall.name === "create_map_tool" && parseMapResult(toolCall.result) !== null) || + (toolCall.name.startsWith("generate_") && + typeof toolCall.result === "string" && + parseAntvImageUrl(toolCall.result) !== null))), {%- elif cookiecutter.enable_charts %} - toolCall.name === "create_chart_tool" && - toolCall.status === "completed" && - parseChartResult(toolCall.result) !== null, + (toolCall.name === "create_chart_tool" && + toolCall.status === "completed" && + parseChartResult(toolCall.result) !== null), {%- elif cookiecutter.enable_antv_charts %} - toolCall.status === "completed" && - ((toolCall.name === "create_map_tool" && parseMapResult(toolCall.result) !== null) || - (toolCall.name.startsWith("generate_") && - typeof toolCall.result === "string" && - parseAntvImageUrl(toolCall.result) !== null)), + (toolCall.status === "completed" && + ((toolCall.name === "create_map_tool" && parseMapResult(toolCall.result) !== null) || + (toolCall.name.startsWith("generate_") && + typeof toolCall.result === "string" && + parseAntvImageUrl(toolCall.result) !== null))), {%- else %} - false, + false, {%- endif %} ); const [showRaw, setShowRaw] = useState(false); @@ -603,6 +697,16 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { ? parseWebSearch(toolCall.result) : null; const isWebSearch = webResults !== null; + const isAskUser = toolCall.name === "ask_user"; +{%- if cookiecutter.enable_skills %} + // pydantic-ai-skills tool names + const isLoadSkill = toolCall.name === "load_skill"; + const isListSkills = toolCall.name === "list_skills"; + const loadedSkillName = + isLoadSkill && typeof toolCall.args?.skill_name === "string" + ? toolCall.args.skill_name + : null; +{%- endif %} {%- if cookiecutter.enable_charts %} // Memoize the parsed chart spec — `parseChartResult` does `JSON.parse` for // string results, returning a NEW object each call. Without this memo, every @@ -649,7 +753,8 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { const hasSpecialRenderer = isDateTime || isRAGSearch || - isWebSearch + isWebSearch || + isAskUser {%- if cookiecutter.enable_charts %} || isChart {%- endif %} @@ -675,7 +780,23 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { : isAntvChart ? antvToolLabel(toolCall.name) {%- endif %} - : toolCall.name; + : isAskUser + ? "Question" +{%- if cookiecutter.enable_skills %} + : isLoadSkill + ? loadedSkillName + ? formatSkillName(loadedSkillName) + : "Load Skill" + : isListSkills + ? "Available Skills" + : toolCall.name === "run_python" + ? "Run Python" + : toolCall.name; +{%- else %} + : toolCall.name === "run_python" + ? "Run Python" + : toolCall.name; +{%- endif %} const ToolIcon = isDateTime ? Clock @@ -693,7 +814,9 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { : isAntvChart ? BarChart3 {%- endif %} - : Wrench; + : isAskUser + ? MessageCircleQuestion + : Wrench; const toggleExpanded = () => { setExpanded((prev) => { @@ -779,7 +902,15 @@ export function ToolCallCard({ toolCall }: ToolCallCardProps) { ) : toolCall.status === "completed" && isAntvChart && antvImageUrl ? ( {%- endif %} + ) : isAskUser ? ( + +{%- if cookiecutter.enable_skills %} + ) : isLoadSkill ? ( + + ) : isListSkills ? null : ( +{%- else %} ) : ( +{%- endif %} )} diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/index.ts b/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/index.ts index 09c9a3e4..64167143 100644 --- a/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/index.ts +++ b/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/index.ts @@ -75,3 +75,5 @@ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./ export { Checkbox } from "./checkbox"; export { RadioGroup, RadioGroupItem } from "./radio-group"; export { Spinner } from "./spinner"; +export { QuestionPrompt } from "./question-prompt"; +export type { QuestionPromptProps } from "./question-prompt"; diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/question-prompt.tsx b/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/question-prompt.tsx new file mode 100644 index 00000000..67c20d00 --- /dev/null +++ b/template/{{cookiecutter.project_slug}}/frontend/src/components/ui/question-prompt.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { CornerDownLeft, Pencil, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "./button"; + +export interface QuestionPromptItem { + question: string; + options?: string[]; + /** Allow a free-form answer via the "Something else" field (default true). */ + allowCustom?: boolean; +} + +export interface QuestionPromptAnswer { + answer: string; + skipped: boolean; +} + +export interface QuestionPromptProps { + /** Questions to ask, in order. The card steps through them one at a time. */ + questions: QuestionPromptItem[]; + /** Disable all controls (e.g. while the socket is offline). */ + disabled?: boolean; + /** Called once every question has been answered or skipped. */ + onComplete: (answers: QuestionPromptAnswer[]) => void; +} + +/** + * Reusable question card. Steps through `questions` one at a time, collecting an + * answer (a chosen option, free text, or a skip) for each, then returns them all + * via `onComplete`. Keyboard: digit keys pick an option, ↑/↓ move focus, Enter + * selects the focused option (or submits the custom answer). + */ +export function QuestionPrompt({ questions, disabled = false, onComplete }: QuestionPromptProps) { + const [step, setStep] = useState(0); + const answersRef = useRef([]); + + if (questions.length === 0) return null; + const total = questions.length; + const current = questions[step]!; + + const commit = (a: QuestionPromptAnswer) => { + const next = [...answersRef.current, a]; + answersRef.current = next; + if (next.length >= total) onComplete(next); + else setStep((s) => s + 1); + }; + + const dismiss = () => { + const remaining = questions + .slice(answersRef.current.length) + .map(() => ({ answer: "", skipped: true })); + onComplete([...answersRef.current, ...remaining]); + }; + + return ( +
+
+ {total > 1 ? ( + + Question {step + 1} of {total} + + ) : ( + + )} + +
+ + = total} + disabled={disabled} + onAnswer={(text) => commit({ answer: text, skipped: false })} + onSkip={() => commit({ answer: "", skipped: true })} + /> +
+ ); +} + +interface SingleQuestionProps { + question: string; + options: string[]; + allowCustom: boolean; + isLast: boolean; + disabled: boolean; + onAnswer: (answer: string) => void; + onSkip: () => void; +} + +function SingleQuestion({ + question, + options, + allowCustom, + isLast, + disabled, + onAnswer, + onSkip, +}: SingleQuestionProps) { + const hasOptions = options.length > 0; + const [focusIdx, setFocusIdx] = useState(0); + // Open the free-form field straight away when there are no options to pick. + const [customOpen, setCustomOpen] = useState(allowCustom && !hasOptions); + const [customText, setCustomText] = useState(""); + const containerRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (customOpen) inputRef.current?.focus(); + else containerRef.current?.focus(); + }, [customOpen]); + + const submitCustom = () => { + const text = customText.trim(); + if (text) onAnswer(text); + }; + + const onListKeyDown = (e: KeyboardEvent) => { + if (disabled || customOpen || !hasOptions) return; + if (/^[1-9]$/.test(e.key)) { + const idx = Number(e.key) - 1; + if (idx < options.length) { + e.preventDefault(); + onAnswer(options[idx]!); + } + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setFocusIdx((i) => Math.min(i + 1, options.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setFocusIdx((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + onAnswer(options[focusIdx]!); + } + }; + + return ( +
+

{question}

+ + {hasOptions && ( +
    + {options.map((option, i) => { + const focused = i === focusIdx && !customOpen; + return ( +
  • + +
  • + ); + })} +
+ )} + + {/* Custom answer / skip row */} +
+ {customOpen ? ( +
+ + setCustomText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + submitCustom(); + } + }} + className="text-foreground placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-sm outline-none" + /> + +
+ ) : ( +
+ {allowCustom ? ( + + ) : ( + + )} + +
+ )} +
+
+ ); +} diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-chat.ts b/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-chat.ts index 5cdde9de..002a6049 100644 --- a/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-chat.ts +++ b/template/{{cookiecutter.project_slug}}/frontend/src/hooks/use-chat.ts @@ -9,6 +9,8 @@ import { useChatStore, useAuthStore, useKBSelectionStore } from "@/stores"; import { useChatStore, useAuthStore } from "@/stores"; {%- endif %} import type { + AskUserAnswer, + AskUserQuestion, ChatMessageFile, Decision, PendingApproval, @@ -69,6 +71,7 @@ export function useChat(options: UseChatOptions = {}) { const thinkingEffortRef = useRef<"low" | "medium" | "high" | null>(null); // Human-in-the-Loop: pending tool approval state const [pendingApproval, setPendingApproval] = useState(null); + const [pendingQuestions, setPendingQuestions] = useState(null); const handleWebSocketMessage = useCallback( (event: MessageEvent) => { @@ -414,6 +417,20 @@ export function useChat(options: UseChatOptions = {}) { break; } + case "ask_user": { + const { questions } = wsEvent.data as { + questions: { question: string; options: string[]; allow_custom: boolean }[]; + }; + setPendingQuestions( + (questions ?? []).map((q) => ({ + question: q.question, + options: q.options ?? [], + allowCustom: q.allow_custom, + })), + ); + break; + } + case "complete": { setIsProcessing(false); // Clear currentMessageId after complete (message_saved should have handled ID mapping) @@ -560,6 +577,15 @@ export function useChat(options: UseChatOptions = {}) { [updateMessage, sendMessage], ); + const sendAskUserResponses = useCallback( + (answers: AskUserAnswer[]) => { + if (!isConnected) return; + setPendingQuestions(null); + sendMessage({ type: "ask_user_response", answers }); + }, + [isConnected, sendMessage], + ); + // Drain message queue when processing finishes AND we're back online. // Re-runs on either flip so a reconnect after offline → drains; a busy turn // ending → drains the next one. @@ -598,5 +624,7 @@ export function useChat(options: UseChatOptions = {}) { // Human-in-the-Loop support pendingApproval, sendResumeDecisions, + pendingQuestions, + sendAskUserResponses, }; } diff --git a/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts b/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts index 3a953f97..9d4834a8 100644 --- a/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts +++ b/template/{{cookiecutter.project_slug}}/frontend/src/types/chat.ts @@ -142,6 +142,7 @@ export type WSEventType = | "message_saved" // DeepAgents Human-in-the-Loop event | "tool_approval_required" + | "ask_user" // CrewAI-specific events | "crew_start" | "crew_started" @@ -236,3 +237,22 @@ export interface ToolApprovalRequiredEvent { review_configs: ReviewConfig[]; }; } + +export interface AskUserQuestion { + question: string; + options: string[]; + /** Whether the user may type a free-form answer instead of picking an option. */ + allowCustom: boolean; +} + +export interface AskUserAnswer { + answer: string; + skipped: boolean; +} + +export interface AskUserEvent { + type: "ask_user"; + data: { + questions: { question: string; options: string[]; allow_custom: boolean }[]; + }; +} diff --git a/tests/test_message_ratings.py b/tests/test_message_ratings.py index f6b251ed..b0d0deb4 100644 --- a/tests/test_message_ratings.py +++ b/tests/test_message_ratings.py @@ -315,10 +315,10 @@ def test_rating_model_passes_ruff(self, project_with_ratings: Path) -> None: / "message_rating.py" ) result = subprocess.run( - ["uv", "run", "ruff", "check", str(model_path)], + ["uvx", "ruff", "check", str(model_path)], capture_output=True, text=True, - cwd=project_with_ratings, + cwd=project_with_ratings / "backend", ) assert result.returncode == 0, f"Ruff failed for message_rating.py:\n{result.stdout}" @@ -329,10 +329,10 @@ def test_rating_repository_passes_ruff(self, project_with_ratings: Path) -> None project_with_ratings / "backend" / "app" / "repositories" / "message_rating.py" ) result = subprocess.run( - ["uv", "run", "ruff", "check", str(repo_path)], + ["uvx", "ruff", "check", str(repo_path)], capture_output=True, text=True, - cwd=project_with_ratings, + cwd=project_with_ratings / "backend", ) assert result.returncode == 0, f"Ruff failed for rating repository:\n{result.stdout}" @@ -343,10 +343,10 @@ def test_rating_service_passes_ruff(self, project_with_ratings: Path) -> None: project_with_ratings / "backend" / "app" / "services" / "message_rating.py" ) result = subprocess.run( - ["uv", "run", "ruff", "check", str(service_path)], + ["uvx", "ruff", "check", str(service_path)], capture_output=True, text=True, - cwd=project_with_ratings, + cwd=project_with_ratings / "backend", ) assert result.returncode == 0, f"Ruff failed for rating service:\n{result.stdout}" @@ -357,10 +357,10 @@ def test_rating_schemas_pass_ruff(self, project_with_ratings: Path) -> None: project_with_ratings / "backend" / "app" / "schemas" / "message_rating.py" ) result = subprocess.run( - ["uv", "run", "ruff", "check", str(schema_path)], + ["uvx", "ruff", "check", str(schema_path)], capture_output=True, text=True, - cwd=project_with_ratings, + cwd=project_with_ratings / "backend", ) assert result.returncode == 0, f"Ruff failed for rating schemas:\n{result.stdout}" @@ -377,10 +377,10 @@ def test_admin_ratings_route_passes_ruff(self, project_with_ratings: Path) -> No / "admin_ratings.py" ) result = subprocess.run( - ["uv", "run", "ruff", "check", str(route_path)], + ["uvx", "ruff", "check", str(route_path)], capture_output=True, text=True, - cwd=project_with_ratings, + cwd=project_with_ratings / "backend", ) assert result.returncode == 0, f"Ruff failed for admin_ratings route:\n{result.stdout}" diff --git a/tests/test_template_integration.py b/tests/test_template_integration.py index 00e08310..7fce07d8 100644 --- a/tests/test_template_integration.py +++ b/tests/test_template_integration.py @@ -90,10 +90,10 @@ def test_minimal_project_passes_ruff(self, generated_project_minimal: Path) -> N """Test minimal project passes ruff check.""" backend_path = generated_project_minimal / "backend" result = subprocess.run( - ["uv", "run", "ruff", "check", str(backend_path)], + ["uvx", "ruff", "check", str(backend_path)], capture_output=True, text=True, - cwd=generated_project_minimal, + cwd=backend_path, ) assert result.returncode == 0, f"Ruff failed:\n{result.stdout}\n{result.stderr}" @@ -102,10 +102,10 @@ def test_full_project_passes_ruff(self, generated_project_full: Path) -> None: """Test full project passes ruff check.""" backend_path = generated_project_full / "backend" result = subprocess.run( - ["uv", "run", "ruff", "check", str(backend_path)], + ["uvx", "ruff", "check", str(backend_path)], capture_output=True, text=True, - cwd=generated_project_full, + cwd=backend_path, ) assert result.returncode == 0, f"Ruff failed:\n{result.stdout}\n{result.stderr}" @@ -305,6 +305,27 @@ def test_full_project_valid_python_syntax(self, generated_project_full: Path) -> enable_charts=True, background_tasks=BackgroundTaskType.NONE, ), + "pydantic_ai_code_execution": dict( + database=DatabaseType.SQLITE, + ai_framework=AIFrameworkType.PYDANTIC_AI, + enable_logfire=False, + enable_code_execution=True, + enable_charts=True, + enable_antv_charts=True, + background_tasks=BackgroundTaskType.NONE, + ), + # SkillsToolset wiring: enable_skills attaches the toolset (no bundled + # skills here, so it no-ops at runtime) — generated together with code + # execution to confirm the two coexist and lint/type-check cleanly. + "pydantic_ai_skills": dict( + database=DatabaseType.SQLITE, + ai_framework=AIFrameworkType.PYDANTIC_AI, + enable_logfire=False, + enable_skills=True, + enable_code_execution=True, + enable_charts=True, + background_tasks=BackgroundTaskType.NONE, + ), "rag_pgvector": dict( database=DatabaseType.POSTGRESQL, background_tasks=BackgroundTaskType.NONE, @@ -350,10 +371,10 @@ class TestGeneratedTemplateMatrix: def test_passes_ruff(self, matrix_project: Path) -> None: backend_path = matrix_project / "backend" result = subprocess.run( - ["uv", "run", "ruff", "check", str(backend_path)], + ["uvx", "ruff", "check", str(backend_path)], capture_output=True, text=True, - cwd=matrix_project, + cwd=backend_path, ) assert result.returncode == 0, f"Ruff failed:\n{result.stdout}\n{result.stderr}"