From 3318b57231d0ce195f33902912353d7faf57c759 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Thu, 7 May 2026 18:18:07 +0300 Subject: [PATCH 01/37] prompt for stateless backend creation --- anton/core/llm/prompts.py | 84 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 73f57da6..f052115c 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -376,6 +376,90 @@ """ +BACKEND_GENERATION_PROMPT = """\ +BACKEND & FULLSTACK APPLICATION GENERATION: + +When the user asks to build a backend service, web application with a backend, or stateless \ +API-driven system, follow this workflow: + +1. CREATE APPLICATION DIRECTORY: Create a folder for the application at \ +{output_dir}/app_name/ (replace 'app_name' with descriptive name). All generated files \ +(backend code, frontend HTML, requirements.txt, config files, etc.) must be saved into \ +this directory. CRITICAL: + - First, check if this directory already exists + - For NEW applications: create a fresh, new directory (do NOT reuse existing folders) + - For EDITING/UPDATING existing applications: only reuse the existing folder if the user \ + explicitly asks to edit or update that application + - If a folder with that name exists and the user is building a NEW app, choose a different \ + app_name or ask the user for confirmation + +2. TECHNICAL SPECIFICATION (as a system analyst): Create a brief technical specification for \ +the application. The specification MUST include: + - Brief description of what the application does (keep it concise) + - Core features and requirements + - REST API specification in markdown format with: + * Endpoints and HTTP methods + * Request/response schemas (JSON examples) + * Error handling + - Framework choice: PREFER Python's built-in http.server or http module if possible. \ + If that's insufficient, use Bottle (simplest, minimal surface area). \ + Only use FastAPI/Flask if the requirements demand it. + - Key dependencies and libraries needed + +3. FETCH & VALIDATE SAMPLE DATA: Using the scratchpad tool: + - Fetch representative sample data from the user's data source (API, database, file) + - Get enough data to understand: structure, data types, volume, and shape + - Answer these questions: + * Is the fetched data sufficient for building the application per the spec? + * Can this data type be used to implement the API as designed? + * Do we need different/more data, or should the spec be revised? + - If the answer to any question is "no" — go back to step 2 and revise the technical \ + specification based on what you learned about the actual data + +4. IMPLEMENT BACKEND: In a dedicated scratchpad, implement the backend code: + - Write the complete backend application (Flask, Bottle, FastAPI, etc.) + - Save it to a file: {output_dir}/app_name/backend.py (or backend_main.py) + - Also save requirements.txt or dependencies file in the same directory + - Use `action='serve'` with `estimated_execution_time_seconds=3600` for long-running \ + web servers + - The backend serves the frontend at `/` (single-origin, no CORS for stateless backends) + - Verify with a test call: test one endpoint to confirm it's working + +5. BUILD FRONTEND (if needed): In a separate scratchpad: + - Build a single-file HTML dashboard or web interface + - Include all CSS and JS inlined (no external file references) + - Follow the VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT guidelines + - Save to {output_dir}/app_name/index.html (or frontend.html) + - It should fetch data from http://localhost:PORT/api/endpoints as defined in step 2 + +6. LAUNCH THE BACKEND: In a new scratchpad, start the server with `action='serve'`: + - Navigate to {output_dir}/app_name/ and run the backend code + - Pass large `estimated_execution_time_seconds` (e.g., 3600) + - The frontend can now connect and pull live data + - Confirm it's reachable by testing an API endpoint + +7. PREVIEW THE APPLICATION: When opening the application in a browser: + - CRITICAL: Open the backend's address and port (e.g., http://localhost:8000), \ + NOT the HTML file from disk (file://...) + - The backend serves the frontend at the root path `/`. Opening localhost:PORT \ + will automatically load the frontend HTML and allow it to make API calls + - If you open the HTML file directly from disk, fetch() calls will fail due to \ + browser CORS/security restrictions (file:// protocol cannot make fetch requests) + - After confirming the backend is running, direct the user to http://localhost:PORT \ + in their browser + +DEPLOYMENT NOTES: +- Backend must be stateless (no mutable global state that matters across requests) +- All data persistence should go through the user's connected data sources (databases, APIs) +- The backend process shuts down when the Anton CLI session ends (per MVP constraints) +- For production, the user must deploy the backend.py file to their own infrastructure + +PUBLISH OR SHARE: +- Publishing is disabled for this MVP (per constraints), but preview is fully supported +- After building, offer to preview the frontend in the browser (action='preview') +- The backend must be running for the frontend to work +""" + CONSOLIDATION_PROMPT = """\ You are a memory consolidation system for an AI coding assistant. From eab7d1a7de7be8fa054578fe849a8abd4e9dae75 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Thu, 7 May 2026 18:19:21 +0300 Subject: [PATCH 02/37] "serve" action for scratchpad --- anton/core/llm/prompt_builder.py | 4 ++++ anton/core/tools/tool_defs.py | 3 ++- anton/core/tools/tool_handlers.py | 34 +++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/anton/core/llm/prompt_builder.py b/anton/core/llm/prompt_builder.py index d7340fe6..20b2e291 100644 --- a/anton/core/llm/prompt_builder.py +++ b/anton/core/llm/prompt_builder.py @@ -5,6 +5,7 @@ from .prompts import ( BASE_VISUALIZATIONS_PROMPT, + BACKEND_GENERATION_PROMPT, CHAT_SYSTEM_PROMPT, VISUALIZATIONS_MARKDOWN_OUTPUT_FORMAT_PROMPT, VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT, @@ -128,6 +129,7 @@ def build( current_datetime: str, system_prompt_context: SystemPromptContext, proactive_dashboards: bool, + output_dir: str, tool_defs: list["ToolDef"] | None = None, memory_context: str = "", project_context: str = "", @@ -152,6 +154,8 @@ def build( current_datetime=current_datetime, ) + prompt += "\n\n" + BACKEND_GENERATION_PROMPT.format(output_dir=output_dir) + tool_prompts = self._build_tool_prompts_section(tool_defs) if tool_prompts: prompt += tool_prompts diff --git a/anton/core/tools/tool_defs.py b/anton/core/tools/tool_defs.py index d4319619..330cebc4 100644 --- a/anton/core/tools/tool_defs.py +++ b/anton/core/tools/tool_defs.py @@ -28,6 +28,7 @@ class ToolDef: "and data persist across cells — like a notebook you drive programmatically.\n\n" "Actions:\n" "- exec: Run code in the scratchpad (creates it if needed)\n" + "- serve: Start a long-running process (web server, background task) without blocking. Returns immediately.\n" "- view: See all cells and their outputs\n" "- reset: Restart the process, clearing all state (installed packages survive)\n" "- remove: Kill the scratchpad and delete its environment\n" @@ -62,7 +63,7 @@ class ToolDef: "properties": { "action": { "type": "string", - "enum": ["exec", "view", "reset", "remove", "dump", "install"], + "enum": ["exec", "serve", "view", "reset", "remove", "dump", "install"], }, "name": {"type": "string", "description": "Scratchpad name"}, "code": { diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index 793f187f..afc56971 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -139,6 +139,8 @@ async def _encode_bg(cortex, entries): async def handle_scratchpad(session: ChatSession, tc_input: dict) -> str: """Dispatch a scratchpad tool call by action.""" + import asyncio + action = tc_input.get("action", "") name = tc_input.get("name", "") @@ -178,6 +180,38 @@ async def handle_scratchpad(session: ChatSession, tc_input: dict) -> str: await _fire_post_execute(session, cell) return format_cell_result(cell) + elif action == "serve": + result = await prepare_scratchpad_exec(session, tc_input) + if isinstance(result, str): + return result + pad, code, description, estimated_time, estimated_seconds = result + + prelim_cell = Cell( + code=code, + stdout="", + stderr="", + error=None, + description=description, + estimated_time=estimated_time or str(estimated_seconds), + ) + await _fire_pre_execute(session, prelim_cell) + + async def _run_background(): + cell = await pad.execute( + code, + description=description, + estimated_time=estimated_time, + estimated_seconds=estimated_seconds, + ) + if cell is not None: + session._record_cell_explainability( + pad_name=name, description=description, cell=cell, + ) + await _fire_post_execute(session, cell) + + asyncio.create_task(_run_background()) + return f"Background server '{name}' started. Process running in the scratchpad." + elif action == "view": # get_or_create: new ChatSession has empty _pads but replayed cells on the # manager — same hydration path as exec so view works on the first tool call. From 2904f7f1d6f9e405505c19de260c09047555954f Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Thu, 7 May 2026 18:20:45 +0300 Subject: [PATCH 03/37] 'output_dir' for prompt formatting --- anton/chat.py | 1 + anton/chat_session.py | 1 + anton/core/session.py | 3 +++ 3 files changed, 5 insertions(+) diff --git a/anton/chat.py b/anton/chat.py index e981000f..f12b05b3 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -1127,6 +1127,7 @@ async def _chat_loop( history_store=history_store, session_id=current_session_id, proactive_dashboards=settings.proactive_dashboards, + output_dir=settings.output_dir, tools=[CONNECT_DATASOURCE_TOOL, PUBLISH_TOOL], )) diff --git a/anton/chat_session.py b/anton/chat_session.py index c7daf543..a85d25aa 100644 --- a/anton/chat_session.py +++ b/anton/chat_session.py @@ -117,4 +117,5 @@ def rebuild_session( history_store=history_store, session_id=session_id, proactive_dashboards=settings.proactive_dashboards, + output_dir=settings.output_dir, )) diff --git a/anton/core/session.py b/anton/core/session.py index 0f2e09f3..92051ce3 100644 --- a/anton/core/session.py +++ b/anton/core/session.py @@ -79,6 +79,7 @@ class ChatSessionConfig: session_id: str | None = None proactive_dashboards: bool = False tools: list[ToolDef] = field(default_factory=list) + output_dir: str = ".anton/output" class ChatSession: @@ -97,6 +98,7 @@ def __init__(self, config: ChatSessionConfig) -> None: self._cortex = config.cortex self._episodic = config.episodic self._system_prompt_context = config.system_prompt_context + self._output_dir = config.output_dir self._proactive_dashboards = config.proactive_dashboards self._extra_tools = config.tools self._workspace = config.workspace @@ -449,6 +451,7 @@ async def _build_system_prompt(self, user_message: str = "") -> str: current_datetime=_current_datetime, system_prompt_context=self._system_prompt_context, proactive_dashboards=self._proactive_dashboards, + output_dir=self._output_dir, tool_defs=self.tool_registry.get_tool_defs(), memory_context=memory_section, project_context=md_context, From dc72fb1d0f68f72e7855026fd6603fe9a699408c Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Fri, 8 May 2026 10:42:42 +0300 Subject: [PATCH 04/37] fix tests --- tests/test_prompt_builder_skills.py | 1 + tests/test_session_skills_init.py | 2 ++ tests/test_skills_e2e.py | 1 + 3 files changed, 4 insertions(+) diff --git a/tests/test_prompt_builder_skills.py b/tests/test_prompt_builder_skills.py index 6796e16e..0dbc6009 100644 --- a/tests/test_prompt_builder_skills.py +++ b/tests/test_prompt_builder_skills.py @@ -42,6 +42,7 @@ def _build_prompt(builder: ChatSystemPromptBuilder, **overrides) -> str: current_datetime="2026-04-10T12:00:00+00:00", system_prompt_context=SystemPromptContext(runtime_context="test runtime"), proactive_dashboards=False, + output_dir="", ) defaults.update(overrides) return builder.build(**defaults) diff --git a/tests/test_session_skills_init.py b/tests/test_session_skills_init.py index 76c584af..e542a61f 100644 --- a/tests/test_session_skills_init.py +++ b/tests/test_session_skills_init.py @@ -69,6 +69,7 @@ def test_section_appears_when_store_passed( current_datetime="2026-04-10", system_prompt_context=SystemPromptContext(runtime_context="test"), proactive_dashboards=False, + output_dir="", skill_store=store_with_one_skill, ) assert "## Procedural memory" in prompt @@ -80,6 +81,7 @@ def test_section_omitted_when_no_store(self): current_datetime="2026-04-10", system_prompt_context=SystemPromptContext(runtime_context="test"), proactive_dashboards=False, + output_dir="", skill_store=None, ) assert "Procedural memory" not in prompt diff --git a/tests/test_skills_e2e.py b/tests/test_skills_e2e.py index 3c673ac4..f4bfb4a0 100644 --- a/tests/test_skills_e2e.py +++ b/tests/test_skills_e2e.py @@ -125,6 +125,7 @@ async def test_full_skills_loop(console, store_root): current_datetime="2026-04-10T13:00:00+00:00", system_prompt_context=SystemPromptContext(runtime_context="test"), proactive_dashboards=False, + output_dir="", skill_store=fresh_store, ) assert "## Procedural memory" in prompt From 0101c815b3ae50a98e5ec82461bf926cbba1d466 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Fri, 8 May 2026 11:16:24 +0300 Subject: [PATCH 05/37] replace output_context to output_dir in prompts --- anton/chat.py | 2 -- anton/chat_session.py | 2 -- anton/core/llm/prompt_builder.py | 13 +++++-------- anton/core/llm/prompts.py | 4 ++-- 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/anton/chat.py b/anton/chat.py index f12b05b3..5831f36f 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -1109,7 +1109,6 @@ async def _chat_loop( # Build runtime context so the LLM knows what it's running on runtime_context = build_runtime_context(settings) - output_path = f"{settings.output_dir.rstrip('/')}/" from anton.chat_session import get_runtime_factory session = ChatSession(ChatSessionConfig( @@ -1120,7 +1119,6 @@ async def _chat_loop( episodic=episodic, system_prompt_context=SystemPromptContext( runtime_context=runtime_context, - output_context=f"Save output to `{output_path}` (create it if needed).", ), workspace=workspace, console=console, diff --git a/anton/chat_session.py b/anton/chat_session.py index a85d25aa..3715d3f4 100644 --- a/anton/chat_session.py +++ b/anton/chat_session.py @@ -101,7 +101,6 @@ def rebuild_session( refresh_knowledge(settings, cortex) runtime_context = build_runtime_context(settings) - output_path = f"{settings.output_dir.rstrip('/')}/" return ChatSession(ChatSessionConfig( llm_client=state["llm_client"], runtime_factory=get_runtime_factory(settings), @@ -110,7 +109,6 @@ def rebuild_session( episodic=episodic, system_prompt_context=SystemPromptContext( runtime_context=runtime_context, - output_context=f"Save output to `{output_path}` (create it if needed).", ), workspace=workspace, console=console, diff --git a/anton/core/llm/prompt_builder.py b/anton/core/llm/prompt_builder.py index 20b2e291..58991335 100644 --- a/anton/core/llm/prompt_builder.py +++ b/anton/core/llm/prompt_builder.py @@ -20,18 +20,15 @@ class SystemPromptContext: """Bundled prompt-injection points for the system prompt. - Four levels with increasing importance (later = stronger influence): + Three levels with increasing importance (later = stronger influence): 1. ``prefix`` — prepended before the base prompt 2. ``runtime_context`` — interpolated into the RUNTIME IDENTITY section - 3. ``output_context`` — free-text instructions on where to - store generated resources (visualizations, HTML files, data exports) - 4. ``suffix`` — appended after all other sections + 3. ``suffix`` — appended after all other sections """ runtime_context: str = "" prefix: str = "" suffix: str = "" - output_context: str = "" class ChatSystemPromptBuilder: @@ -111,7 +108,7 @@ def _build_visualizations_section( self, *, proactive_dashboards: bool, - output_context: str, + output_dir: str, ) -> str: visualizations_output_format_prompt = ( VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT @@ -119,7 +116,7 @@ def _build_visualizations_section( else VISUALIZATIONS_MARKDOWN_OUTPUT_FORMAT_PROMPT ) output_format = visualizations_output_format_prompt.format( - output_context=output_context, + output_dir=output_dir, ) return BASE_VISUALIZATIONS_PROMPT.format(output_format=output_format) @@ -139,7 +136,7 @@ def build( ) -> str: visualizations_section = self._build_visualizations_section( proactive_dashboards=proactive_dashboards, - output_context=system_prompt_context.output_context, + output_dir=output_dir, ) prompt = "" diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index f052115c..60b1b95f 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -300,7 +300,7 @@ Output format: - Unless the user explicitly asks for a different format, always output visualizations \ as polished, single-file HTML pages — never raw PNGs or bare image files. -{output_context} +Save output to `{output_dir}` (create it if needed). Visual design: - Make it look good by default. Use a dark theme (#0d1117 background, #e6edf3 text), \ @@ -369,7 +369,7 @@ - For large datasets, summarize the top N and offer to show more. - When the user EXPLICITLY asks for a chart, dashboard, plot, or HTML visualization, \ THEN build it as a self-contained HTML file with inlined CSS, JS, and data. \ -{output_context} +Save output to `{output_dir}` (create it if needed). Use Apache ECharts (CDN), dark theme (#0d1117), and follow standard dashboard best practices. \ If the dataset is very large (>100KB), write it to a separate .js file in the same directory. \ Never split CSS or chart logic into separate files — only large data payloads.\ From aabc2f1792805d2958a00b9028d0c7fc3d0de830 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Fri, 8 May 2026 11:50:45 +0300 Subject: [PATCH 06/37] make publichable artifacts serach better --- anton/chat.py | 46 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/anton/chat.py b/anton/chat.py index 5831f36f..f29a442e 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -317,6 +317,22 @@ async def _handle_connect( ) +def _is_publishable_html(html_path: Path, output_dir: Path) -> bool: + """Check if an HTML file is publishable. + + Returns False if: + - HTML is in a subdirectory that contains .py files (fullstack app) + + Returns True if: + - HTML is in the root of output/ + - HTML is in a subdirectory without any .py files + """ + if html_path.parent == output_dir: + return True + + parent_dir = html_path.parent + has_py_files = any(parent_dir.glob("*.py")) + return not has_py_files def _extract_html_title(path, re_module) -> str: @@ -445,12 +461,19 @@ async def _handle_publish( if not target.is_absolute(): target = Path(settings.workspace_path) / file_arg else: - # List HTML files sorted by modification time (most recent first) - html_files = sorted( - output_dir.glob("*.html"), key=lambda f: f.stat().st_mtime, reverse=True - ) if output_dir.is_dir() else [] + # List publishable HTML files sorted by modification time (most recent first) + if output_dir.is_dir(): + all_html = list(output_dir.rglob("*.html")) + html_files = sorted( + [f for f in all_html if _is_publishable_html(f, output_dir)], + key=lambda f: f.stat().st_mtime, + reverse=True, + ) + else: + html_files = [] + if not html_files: - console.print(" [anton.warning]No HTML files found in .anton/output/[/]") + console.print(" [anton.warning]No publishable HTML files found in .anton/output/[/]") console.print() return @@ -464,9 +487,10 @@ async def _handle_publish( console.print(" [anton.cyan]Available reports:[/]") console.print() for i, f in enumerate(page, offset + 1): + rel_path = f.relative_to(output_dir).as_posix() title = _extract_html_title(f, re) label = title or f.name - console.print(f" [bold]{i}[/] {label} [anton.muted]{f.name}[/]") + console.print(f" [bold]{i}[/] {label} [anton.muted]{rel_path}[/]") if has_more: console.print(f"\n [anton.muted]m Show more ({len(html_files) - offset - PAGE_SIZE} remaining)[/]") @@ -498,6 +522,14 @@ async def _handle_publish( console.print() return + # Check if file is publishable + if not _is_publishable_html(target, output_dir): + console.print(" [anton.error]Cannot publish this HTML file:[/]") + console.print(" It is in a directory with Python files (fullstack application).") + console.print(" Only standalone HTML reports can be published.") + console.print() + return + # 3. Check if this file was previously published published_json = output_dir / ".published.json" published_map = {} @@ -508,7 +540,7 @@ async def _handle_publish( pass report_id = None - file_key = target.name + file_key = target.relative_to(output_dir).as_posix() prev = published_map.get(file_key) if prev and prev.get("report_id"): From 9fb5de5804fe5b0771b2697b4dc351bc71c81b4f Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Tue, 12 May 2026 16:36:15 +0300 Subject: [PATCH 07/37] gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fc7b86e0..d312f639 100644 --- a/.gitignore +++ b/.gitignore @@ -208,4 +208,5 @@ __marimo__/ # Anton .anton/ -.DS_Store \ No newline at end of file +.DS_Store +artifacts/ From b0a01a06bf43906fec81974c1c7b5cc56119e90f Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Tue, 12 May 2026 16:38:38 +0300 Subject: [PATCH 08/37] save port in metadata.json --- anton/core/artifacts/models.py | 1 + anton/core/artifacts/store.py | 30 ++++++++++++++------- anton/core/llm/prompts.py | 28 +++++++++++--------- anton/core/session.py | 4 +-- anton/core/tools/tool_defs.py | 28 +++++++++++--------- anton/core/tools/tool_handlers.py | 23 +++++++++------- tests/test_artifacts.py | 44 ++++++++++++++++++++++++------- 7 files changed, 104 insertions(+), 54 deletions(-) diff --git a/anton/core/artifacts/models.py b/anton/core/artifacts/models.py index 96bd3146..5940dbe5 100644 --- a/anton/core/artifacts/models.py +++ b/anton/core/artifacts/models.py @@ -108,6 +108,7 @@ class Artifact(BaseModel): # most cases — they generally know the filename they're going # to write). primary: str | None = None + port: int | None = None # ── Server-managed contents ───────────────────────────────── files: list[FileEntry] = Field(default_factory=list) diff --git a/anton/core/artifacts/store.py b/anton/core/artifacts/store.py index 636a691d..4553622a 100644 --- a/anton/core/artifacts/store.py +++ b/anton/core/artifacts/store.py @@ -58,6 +58,9 @@ def _new_id() -> str: return uuid.uuid4().hex[:8] +_UNSET = object() + + def _sanitize_slug(value: str) -> str: """Map any name to a folder-safe slug. @@ -172,20 +175,29 @@ def create( self._save(artifact) return artifact - def set_primary(self, slug: str, primary: str | None) -> Artifact | None: - """Update the primary-file pointer on an existing artifact. + def update( + self, + slug: str, + *, + primary: str | None = _UNSET, # type: ignore[assignment] + port: int | None = _UNSET, # type: ignore[assignment] + ) -> Artifact | None: + """Update mutable agent-supplied fields on an existing artifact. - Used when the agent created with no `primary` and decided - later, or when the primary file got renamed. Pass `None` to - clear (the renderer reverts to the heuristic). Returns the - updated artifact, or None when the slug is missing. + Only fields explicitly passed are modified; omitted fields are + left unchanged. Pass `primary=None` or `primary=""` to clear + the entry-point pointer. Pass `port=None` to clear the port. + Returns the updated artifact, or None when the slug is missing. """ artifact = self._load_silent(slug) if artifact is None: return None - artifact.primary = ( - primary.strip() if isinstance(primary, str) and primary.strip() else None - ) + if primary is not _UNSET: + artifact.primary = ( + primary.strip() if isinstance(primary, str) and primary.strip() else None + ) + if port is not _UNSET: + artifact.port = int(port) if port is not None else None artifact.updatedAt = _utc_now() self._save(artifact) return artifact diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 60b1b95f..fefab577 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -382,16 +382,15 @@ When the user asks to build a backend service, web application with a backend, or stateless \ API-driven system, follow this workflow: -1. CREATE APPLICATION DIRECTORY: Create a folder for the application at \ -{output_dir}/app_name/ (replace 'app_name' with descriptive name). All generated files \ -(backend code, frontend HTML, requirements.txt, config files, etc.) must be saved into \ -this directory. CRITICAL: - - First, check if this directory already exists - - For NEW applications: create a fresh, new directory (do NOT reuse existing folders) - - For EDITING/UPDATING existing applications: only reuse the existing folder if the user \ - explicitly asks to edit or update that application - - If a folder with that name exists and the user is building a NEW app, choose a different \ - app_name or ask the user for confirmation +1. REGISTER THE ARTIFACT: Call the `create_artifact` tool BEFORE creating any files. \ +This creates the folder, `metadata.json`, and `README.md` automatically and returns the \ +absolute folder path. Use that path for ALL subsequent file writes. + - `name`: short human-readable app name (e.g. "Weather Dashboard") + - `description`: one sentence describing what the app does + - `type`: always `"fullstack-stateful-app"` — every app built here requires a backend process + - `primary`: set to `"index.html"` when you know that will be the frontend entry-point + For EDITING an existing app: call `list_artifacts` first to find it, then \ +`open_artifact(slug)` to get the folder path — do NOT call `create_artifact` again. 2. TECHNICAL SPECIFICATION (as a system analyst): Create a brief technical specification for \ the application. The specification MUST include: @@ -418,7 +417,8 @@ 4. IMPLEMENT BACKEND: In a dedicated scratchpad, implement the backend code: - Write the complete backend application (Flask, Bottle, FastAPI, etc.) - - Save it to a file: {output_dir}/app_name/backend.py (or backend_main.py) + - Save it to a file: `/backend.py` (or backend_main.py), \ +where `` is the folder path returned by `create_artifact` in step 1 - Also save requirements.txt or dependencies file in the same directory - Use `action='serve'` with `estimated_execution_time_seconds=3600` for long-running \ web servers @@ -429,14 +429,16 @@ - Build a single-file HTML dashboard or web interface - Include all CSS and JS inlined (no external file references) - Follow the VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT guidelines - - Save to {output_dir}/app_name/index.html (or frontend.html) + - Save to `/index.html` (or frontend.html) - It should fetch data from http://localhost:PORT/api/endpoints as defined in step 2 6. LAUNCH THE BACKEND: In a new scratchpad, start the server with `action='serve'`: - - Navigate to {output_dir}/app_name/ and run the backend code + - Navigate to `/` and run the backend code - Pass large `estimated_execution_time_seconds` (e.g., 3600) - The frontend can now connect and pull live data - Confirm it's reachable by testing an API endpoint + - After confirming the endpoint responds, call `update_artifact(slug=, port=)` \ +to record the port in metadata.json 7. PREVIEW THE APPLICATION: When opening the application in a browser: - CRITICAL: Open the backend's address and port (e.g., http://localhost:8000), \ diff --git a/anton/core/session.py b/anton/core/session.py index 1ccec981..ef3cb132 100644 --- a/anton/core/session.py +++ b/anton/core/session.py @@ -33,7 +33,7 @@ OPEN_ARTIFACT_TOOL, RECALL_TOOL, SCRATCHPAD_TOOL, - SET_ARTIFACT_PRIMARY_TOOL, + UPDATE_ARTIFACT_METADATA_TOOL, ToolDef, ) from anton.core.utils.scratchpad import prepare_scratchpad_exec, format_cell_result @@ -564,7 +564,7 @@ def _build_core_tools(self) -> None: self.tool_registry.register_tool(CREATE_ARTIFACT_TOOL) self.tool_registry.register_tool(LIST_ARTIFACTS_TOOL) self.tool_registry.register_tool(OPEN_ARTIFACT_TOOL) - self.tool_registry.register_tool(SET_ARTIFACT_PRIMARY_TOOL) + self.tool_registry.register_tool(UPDATE_ARTIFACT_METADATA_TOOL) async def close(self) -> None: """Clean up scratchpads and other resources.""" diff --git a/anton/core/tools/tool_defs.py b/anton/core/tools/tool_defs.py index e4a0bd97..feecb2bb 100644 --- a/anton/core/tools/tool_defs.py +++ b/anton/core/tools/tool_defs.py @@ -5,7 +5,7 @@ handle_open_artifact, handle_recall, handle_scratchpad, - handle_set_artifact_primary, + handle_update_artifact_metadata, ) from dataclasses import dataclass @@ -165,7 +165,7 @@ class ToolDef: "`\"index.html\"` for a fullstack app, `\"report.pdf\"` for a " "document. The renderer uses it to decide what to open by default. " "Skip when you don't know yet — the renderer falls back to a " - "heuristic, and you can set it later via `set_artifact_primary`.\n\n" + "heuristic, and you can set it later via `update_artifact`.\n\n" "To MODIFY an existing artifact instead of creating a new one, call " "`list_artifacts` first to find it, then `open_artifact(slug)` to get " "the path." @@ -204,15 +204,15 @@ class ToolDef: ) -SET_ARTIFACT_PRIMARY_TOOL = ToolDef( - name="set_artifact_primary", +UPDATE_ARTIFACT_METADATA_TOOL = ToolDef( + name="update_artifact", description=( - "Update the primary-file pointer on an existing artifact. Call this " - "when you created the artifact without a `primary` and now know what " - "it should be, or when the entry-point file's name changed. Pass an " - "empty string or omit `primary` to clear (the renderer reverts to " - "its heuristic — `index.html` → newest `.html` → newest non-" - "housekeeping file)." + "Update mutable fields on an existing artifact. Pass only the fields you want to change.\n\n" + "- `primary`: relative path of the entry-point file (e.g. \"index.html\"). " + "Pass empty string to clear (renderer reverts to heuristic: " + "`index.html` → newest `.html` → newest non-housekeeping file).\n" + "- `port`: port the backend process is listening on (fullstack-stateful-app only). " + "Set this after the server confirms it is up." ), input_schema={ "type": "object", @@ -223,12 +223,16 @@ class ToolDef: }, "primary": { "type": "string", - "description": "Relative path of the new entry-point file. Empty string to clear.", + "description": "Relative path of the entry-point file. Empty string to clear.", + }, + "port": { + "type": "integer", + "description": "Port number the backend process is listening on.", }, }, "required": ["slug"], }, - handler=handle_set_artifact_primary, + handler=handle_update_artifact_metadata, ) diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index cb2a20d1..47255d2a 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -117,13 +117,12 @@ async def handle_create_artifact(session: "ChatSession", tc_input: dict) -> str: }, indent=2) -async def handle_set_artifact_primary(session: "ChatSession", tc_input: dict) -> str: - """Update or clear the primary-file pointer on an existing artifact. +async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict) -> str: + """Update mutable metadata fields on an existing artifact. - The agent calls this when it created an artifact without a - primary and now knows what it should be, or when the primary - file's name changed. Pass `primary: null` to clear and revert - the renderer to its heuristic. + Only fields present in the input are modified. Supports: + - `primary`: entry-point file path (empty string to clear) + - `port`: backend port number (fullstack-stateful-app only) """ import json @@ -134,14 +133,20 @@ async def handle_set_artifact_primary(session: "ChatSession", tc_input: dict) -> slug = (tc_input.get("slug") or "").strip() if not slug: return "Error: `slug` is required." - raw = tc_input.get("primary") - primary = raw if isinstance(raw, str) else None - artifact = store.set_primary(slug, primary) + + kwargs: dict = {} + if "primary" in tc_input: + kwargs["primary"] = tc_input["primary"] + if "port" in tc_input: + kwargs["port"] = tc_input["port"] + + artifact = store.update(slug, **kwargs) if artifact is None: return f"Error: no artifact found for slug `{slug}`." return json.dumps({ "slug": artifact.slug, "primary": artifact.primary, + "port": artifact.port, }, indent=2) diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index 551f7e70..9753c25a 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -373,9 +373,9 @@ def test_create_strips_blank_primary(store: ArtifactStore): assert artifact.primary is None -def test_set_primary_updates(store: ArtifactStore): +def test_update_primary(store: ArtifactStore): artifact = store.create(name="X", description="x", type="html-app") - updated = store.set_primary(artifact.slug, "main.html") + updated = store.update(artifact.slug, primary="main.html") assert updated is not None assert updated.primary == "main.html" # Persisted: re-loading the same slug returns the new value. @@ -383,22 +383,48 @@ def test_set_primary_updates(store: ArtifactStore): assert reloaded.primary == "main.html" -def test_set_primary_clears_with_none(store: ArtifactStore): +def test_update_primary_clears_with_none(store: ArtifactStore): artifact = store.create( name="X", description="x", type="html-app", primary="dashboard.html", ) - cleared = store.set_primary(artifact.slug, None) + cleared = store.update(artifact.slug, primary=None) assert cleared.primary is None - # Empty string is also treated as "clear" — same intent the - # tool's input schema documents. + # Empty string is also treated as "clear". artifact2 = store.create( name="Y", description="x", type="html-app", primary="dashboard.html", ) - cleared2 = store.set_primary(artifact2.slug, " ") + cleared2 = store.update(artifact2.slug, primary=" ") assert cleared2.primary is None -def test_set_primary_returns_none_for_missing_slug(store: ArtifactStore): - assert store.set_primary("does-not-exist", "main.html") is None +def test_update_port(store: ArtifactStore): + artifact = store.create(name="App", description="x", type="fullstack-stateful-app") + updated = store.update(artifact.slug, port=8080) + assert updated is not None + assert updated.port == 8080 + reloaded = store.open(artifact.slug) + assert reloaded.port == 8080 + + +def test_update_primary_and_port_together(store: ArtifactStore): + artifact = store.create(name="App", description="x", type="fullstack-stateful-app") + updated = store.update(artifact.slug, primary="index.html", port=5000) + assert updated.primary == "index.html" + assert updated.port == 5000 + + +def test_update_omitted_field_unchanged(store: ArtifactStore): + artifact = store.create( + name="App", description="x", type="fullstack-stateful-app", + primary="index.html", + ) + # Updating only port must not touch primary. + updated = store.update(artifact.slug, port=3000) + assert updated.primary == "index.html" + assert updated.port == 3000 + + +def test_update_returns_none_for_missing_slug(store: ArtifactStore): + assert store.update("does-not-exist", primary="main.html") is None From 85df1a371f3cf2da39516ab8e686305738a0b700 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Thu, 14 May 2026 14:43:08 +0300 Subject: [PATCH 09/37] update prompt --- anton/core/llm/prompts.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index fefab577..97ab8ec2 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -430,7 +430,10 @@ - Include all CSS and JS inlined (no external file references) - Follow the VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT guidelines - Save to `/index.html` (or frontend.html) - - It should fetch data from http://localhost:PORT/api/endpoints as defined in step 2 + - API calls MUST use RELATIVE paths only (e.g. `fetch('/api/items')`, NOT \ +`fetch('http://localhost:PORT/api/items')` and NOT any hardcoded base URL). \ +The frontend is served by the same backend at `/`, so relative paths resolve to the \ +correct origin automatically — this keeps the app portable across ports and hosts. 6. LAUNCH THE BACKEND: In a new scratchpad, start the server with `action='serve'`: - Navigate to `/` and run the backend code From 6a8e4f03bdcef1bda1e526950d48572222747702 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Fri, 15 May 2026 14:27:33 +0300 Subject: [PATCH 10/37] start backend using new 'launch_backend' tool --- anton/core/backends/base.py | 10 ++ anton/core/backends/local.py | 11 ++ anton/core/backends/manager.py | 11 ++ anton/core/llm/prompts.py | 57 +++++---- anton/core/session.py | 29 +++++ anton/core/tools/tool_defs.py | 48 +++++++ anton/core/tools/tool_handlers.py | 204 ++++++++++++++++++++++++++++++ 7 files changed, 345 insertions(+), 25 deletions(-) diff --git a/anton/core/backends/base.py b/anton/core/backends/base.py index c45a1147..77148f68 100644 --- a/anton/core/backends/base.py +++ b/anton/core/backends/base.py @@ -92,6 +92,16 @@ async def cleanup(self) -> None: Unlike close(), cleanup() removes persistent storage too. """ + def venv_python(self) -> str | None: + """Path to the runtime's Python interpreter, if locally accessible. + + Used by tools (e.g. launch_backend) that need to spawn auxiliary + processes sharing the scratchpad's installed packages. Returns + None for runtimes whose interpreter isn't reachable from the + host process (e.g. remote / Lightsail backends). + """ + return None + async def execute( self, code: str, diff --git a/anton/core/backends/local.py b/anton/core/backends/local.py index d3fdccf0..67a7010c 100644 --- a/anton/core/backends/local.py +++ b/anton/core/backends/local.py @@ -161,6 +161,17 @@ def _create_venv(self) -> None: bin_dir = os.path.join(self._venv_dir, "bin") self._venv_python = os.path.join(bin_dir, "python") + def venv_python(self) -> str | None: + """Public accessor for the scratchpad's Python interpreter path. + + Returns None when the venv has not been provisioned yet (i.e. + no exec has run). Auxiliary tools that want to share installed + packages call this to discover the interpreter. + """ + if self._venv_python and os.path.isfile(self._venv_python): + return self._venv_python + return None + def _verify_venv_python(self) -> bool: if self._venv_python is None: return False diff --git a/anton/core/backends/manager.py b/anton/core/backends/manager.py index f1d7d7fe..3c684872 100644 --- a/anton/core/backends/manager.py +++ b/anton/core/backends/manager.py @@ -84,3 +84,14 @@ async def close_all(self) -> None: for pad in self._pads.values(): await pad.close() self._pads.clear() + + async def venv_python(self, name: str = "main") -> str | None: + """Return the Python interpreter path of the named scratchpad. + + Provisions the scratchpad on demand so callers don't have to + synchronize with whatever cell the LLM happens to be running. + Returns None when the runtime can't expose a local interpreter + (e.g. remote backends). + """ + pad = await self.get_or_create(name) + return pad.venv_python() diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 97ab8ec2..021a100e 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -416,14 +416,17 @@ specification based on what you learned about the actual data 4. IMPLEMENT BACKEND: In a dedicated scratchpad, implement the backend code: - - Write the complete backend application (Flask, Bottle, FastAPI, etc.) - - Save it to a file: `/backend.py` (or backend_main.py), \ -where `` is the folder path returned by `create_artifact` in step 1 - - Also save requirements.txt or dependencies file in the same directory - - Use `action='serve'` with `estimated_execution_time_seconds=3600` for long-running \ - web servers + - Write the complete backend application (http.server, Bottle, Flask, FastAPI, etc.) + - Save it to `/backend.py`, where `` is the folder \ +path returned by `create_artifact` in step 1 + - If the backend uses any non-stdlib libraries (Bottle, Flask, FastAPI, requests, \ +pandas, etc.), save a `requirements.txt` in the same directory listing them. \ +If the backend uses ONLY the Python standard library (http.server, json, sqlite3, etc.), \ +do NOT create requirements.txt. + - The backend MUST accept `--port` via argparse and bind to that port. \ +NEVER hardcode the port — `launch_backend` picks a free one and passes it in. - The backend serves the frontend at `/` (single-origin, no CORS for stateless backends) - - Verify with a test call: test one endpoint to confirm it's working + - Do NOT start the server inside the scratchpad — use `launch_backend` in step 6. 5. BUILD FRONTEND (if needed): In a separate scratchpad: - Build a single-file HTML dashboard or web interface @@ -435,23 +438,26 @@ The frontend is served by the same backend at `/`, so relative paths resolve to the \ correct origin automatically — this keeps the app portable across ports and hosts. -6. LAUNCH THE BACKEND: In a new scratchpad, start the server with `action='serve'`: - - Navigate to `/` and run the backend code - - Pass large `estimated_execution_time_seconds` (e.g., 3600) - - The frontend can now connect and pull live data - - Confirm it's reachable by testing an API endpoint - - After confirming the endpoint responds, call `update_artifact(slug=, port=)` \ -to record the port in metadata.json - -7. PREVIEW THE APPLICATION: When opening the application in a browser: - - CRITICAL: Open the backend's address and port (e.g., http://localhost:8000), \ - NOT the HTML file from disk (file://...) - - The backend serves the frontend at the root path `/`. Opening localhost:PORT \ - will automatically load the frontend HTML and allow it to make API calls - - If you open the HTML file directly from disk, fetch() calls will fail due to \ - browser CORS/security restrictions (file:// protocol cannot make fetch requests) - - After confirming the backend is running, direct the user to http://localhost:PORT \ - in their browser +6. LAUNCH THE BACKEND: Call the `launch_backend` tool with the artifact's slug: + - `launch_backend(slug=)` — the tool picks a free port, spawns \ +`python backend.py --port ` as a standalone process with `` as cwd, \ +waits for readiness, writes the port into `metadata.json`, and returns \ +`{{slug, port, pid, url, log_path}}` as JSON. + - Backend stdout/stderr stream to `/backend.log` — read it if \ +the launch fails or the API misbehaves. + - Do NOT call `update_artifact(port=...)` manually — `launch_backend` does it. + - The launched process outlives the scratchpad cell and is reaped automatically \ +when the Anton session ends. + - Calling `launch_backend` again for the same slug terminates the previous \ +process and starts a fresh one — use this for hot reloads after code changes. + +7. PREVIEW THE APPLICATION: Direct the user to the `url` returned by `launch_backend` \ +(e.g. http://127.0.0.1:54321): + - CRITICAL: Open that URL, NOT the HTML file from disk (file://...). \ +The backend serves the frontend at `/`, so opening the URL loads the page and \ +its `fetch()` calls land on the same origin. + - If the user opens the HTML file directly from disk, `fetch()` calls fail due \ +to browser CORS/file:// restrictions. DEPLOYMENT NOTES: - Backend must be stateless (no mutable global state that matters across requests) @@ -461,7 +467,8 @@ PUBLISH OR SHARE: - Publishing is disabled for this MVP (per constraints), but preview is fully supported -- After building, offer to preview the frontend in the browser (action='preview') +- After building, offer to preview the frontend by directing the user to the \ +URL returned by `launch_backend` - The backend must be running for the frontend to work """ diff --git a/anton/core/session.py b/anton/core/session.py index ef3cb132..6436959f 100644 --- a/anton/core/session.py +++ b/anton/core/session.py @@ -28,6 +28,7 @@ from anton.core.tools.registry import ToolRegistry from anton.core.tools.tool_defs import ( CREATE_ARTIFACT_TOOL, + LAUNCH_BACKEND_TOOL, LIST_ARTIFACTS_TOOL, MEMORIZE_TOOL, OPEN_ARTIFACT_TOOL, @@ -162,6 +163,11 @@ def __init__(self, config: ChatSessionConfig) -> None: # at the start of each turn. Prevents double-summarization when # the post-recovery response still reports high pressure. self._compacted_this_turn = False + # Backends launched via the launch_backend tool. Keyed by + # artifact slug; each entry holds the asyncio.subprocess.Process + # plus its port. Reaped in close() so backend processes don't + # outlive the chat session. + self._tracked_backends: dict[str, dict] = {} @property def history(self) -> list[dict]: @@ -565,11 +571,34 @@ def _build_core_tools(self) -> None: self.tool_registry.register_tool(LIST_ARTIFACTS_TOOL) self.tool_registry.register_tool(OPEN_ARTIFACT_TOOL) self.tool_registry.register_tool(UPDATE_ARTIFACT_METADATA_TOOL) + self.tool_registry.register_tool(LAUNCH_BACKEND_TOOL) async def close(self) -> None: """Clean up scratchpads and other resources.""" + await self._reap_tracked_backends() await self._scratchpads.close_all() + async def _reap_tracked_backends(self) -> None: + """Terminate every backend launched via launch_backend. + + SIGTERM first, then SIGKILL after a short grace period. Errors + are swallowed — close() must not raise on shutdown. + """ + for slug, info in list(self._tracked_backends.items()): + proc = info.get("proc") + if proc is None or proc.returncode is not None: + continue + try: + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=3) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + except (ProcessLookupError, OSError): + pass + self._tracked_backends.clear() + async def _summarize_history(self) -> None: """Compress old conversation turns into a summary using the coding model. diff --git a/anton/core/tools/tool_defs.py b/anton/core/tools/tool_defs.py index feecb2bb..72a4c371 100644 --- a/anton/core/tools/tool_defs.py +++ b/anton/core/tools/tool_defs.py @@ -1,5 +1,6 @@ from anton.core.tools.tool_handlers import ( handle_create_artifact, + handle_launch_backend, handle_list_artifacts, handle_memorize, handle_open_artifact, @@ -277,6 +278,53 @@ class ToolDef: ) +LAUNCH_BACKEND_TOOL = ToolDef( + name="launch_backend", + description=( + "Start an artifact's backend script as a standalone subprocess. " + "Picks a free TCP port, runs the script with `--port ` " + "(plus any `extra_args`), waits until the server is reachable, " + "records the port in the artifact's `metadata.json`, and returns " + "`{slug, port, pid, url, log_path}` as JSON.\n\n" + "Idempotent: a second call with the same slug terminates the " + "previously-launched backend before starting a new one.\n\n" + "Requirements on the backend script:\n" + "- MUST accept `--port` via argparse (or equivalent) and bind to it.\n" + "- MUST be reachable at `health_path` (default `/`) within " + "`health_timeout` seconds.\n" + "- stdout/stderr stream to `/backend.log`." + ), + input_schema={ + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "Folder slug of the artifact whose backend to launch.", + }, + "path": { + "type": "string", + "description": "Backend script path relative to the artifact folder. Default: \"backend.py\".", + }, + "extra_args": { + "type": "array", + "items": {"type": "string"}, + "description": "Additional CLI arguments appended after `--port `.", + }, + "health_path": { + "type": "string", + "description": "URL path for the readiness probe. Default: \"/\". Any HTTP response (including 4xx) counts as ready.", + }, + "health_timeout": { + "type": "number", + "description": "Seconds to wait for readiness before failing. Default: 10.", + }, + }, + "required": ["slug"], + }, + handler=handle_launch_backend, +) + + RECALL_TOOL = ToolDef( name="recall", description=( diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index 47255d2a..73795025 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -150,6 +150,210 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict }, indent=2) +async def handle_launch_backend(session: "ChatSession", tc_input: dict) -> str: + """Launch the artifact's backend script as a standalone subprocess. + + Picks a free TCP port, runs + ` --port [...extra_args]` + with the artifact folder as cwd, waits for the server to become + reachable, persists the port in metadata.json, and tracks the + process on the session so it can be reaped on close. + + Idempotent: a fresh call for the same slug terminates any + previously-tracked backend before launching the new one. + """ + import asyncio + import json + import os + import signal + import socket + import sys + import urllib.error + import urllib.request + + store = _artifact_store(session) + if store is None: + return "Artifact store unavailable (no workspace bound to this session)." + + slug = (tc_input.get("slug") or "").strip() + if not slug: + return "Error: `slug` is required." + artifact = store.open(slug) + if artifact is None: + return f"Error: no artifact found for slug `{slug}`." + + folder = store.folder_for(slug) + rel_path = (tc_input.get("path") or "backend.py").strip() + script = (folder / rel_path).resolve() + try: + script.relative_to(folder.resolve()) + except ValueError: + return f"Error: `path` must stay within the artifact folder ({folder})." + if not script.is_file(): + return f"Error: backend script not found at {script}." + + extra_args = tc_input.get("extra_args") or [] + if not isinstance(extra_args, list) or not all(isinstance(x, str) for x in extra_args): + return "Error: `extra_args` must be a list of strings." + health_path = tc_input.get("health_path") or "/" + if not health_path.startswith("/"): + health_path = "/" + health_path + try: + health_timeout = float(tc_input.get("health_timeout", 10)) + except (TypeError, ValueError): + return "Error: `health_timeout` must be a number." + + venv_python = await session._scratchpads.venv_python() + if not venv_python: + return ( + "Error: scratchpad venv Python is not available. " + "This usually means the runtime is remote, or no scratchpad cell " + "has run yet to provision the venv." + ) + + tracked = getattr(session, "_tracked_backends", None) + if tracked is None: + tracked = {} + session._tracked_backends = tracked + + # Reap any previously-tracked backend for this slug before launching + # the new one — keeps the call idempotent across hot reloads. + prev = tracked.pop(slug, None) + if prev is not None: + prev_proc = prev.get("proc") + if prev_proc is not None and prev_proc.returncode is None: + try: + prev_proc.terminate() + try: + await asyncio.wait_for(prev_proc.wait(), timeout=3) + except asyncio.TimeoutError: + prev_proc.kill() + await prev_proc.wait() + except ProcessLookupError: + pass + + # Bind-and-close to discover a free port. There is a TOCTOU window + # before the backend picks it up — acceptable in single-user dev. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + + cmd = [venv_python, str(script), "--port", str(port), *extra_args] + log_path = folder / "backend.log" + log_fd = open(log_path, "ab", buffering=0) + + # PR_SET_PDEATHSIG so the backend dies with Anton on Linux. macOS + # has no equivalent; we rely on close() to reap there. + preexec_fn = None + if sys.platform.startswith("linux"): + def _set_pdeathsig() -> None: + try: + import ctypes + + libc = ctypes.CDLL("libc.so.6", use_errno=True) + PR_SET_PDEATHSIG = 1 + libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM, 0, 0, 0) + except Exception: + pass + + preexec_fn = _set_pdeathsig + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + cwd=str(folder), + stdout=log_fd, + stderr=log_fd, + stdin=asyncio.subprocess.DEVNULL, + preexec_fn=preexec_fn, + env={**os.environ}, + ) + except OSError as exc: + log_fd.close() + return f"Error: failed to spawn backend: {exc}" + finally: + # The subprocess holds its own dup of the fd; we can close ours. + try: + log_fd.close() + except OSError: + pass + + # Readiness — try HTTP first, fall back to TCP-connect. HTTP 4xx + # still counts as "process is alive and answering" → ready. + loop = asyncio.get_event_loop() + deadline = loop.time() + health_timeout + ready = False + last_err: str | None = None + while loop.time() < deadline: + if proc.returncode is not None: + tail = "" + try: + tail = log_path.read_text(errors="replace")[-2000:] + except OSError: + pass + return ( + f"Error: backend exited early (rc={proc.returncode}) before " + f"binding to :{port}.\nLog tail:\n{tail}" + ) + url = f"http://127.0.0.1:{port}{health_path}" + try: + await asyncio.wait_for( + loop.run_in_executor( + None, lambda: urllib.request.urlopen(url, timeout=1).close() + ), + timeout=1.5, + ) + ready = True + break + except urllib.error.HTTPError: + # 4xx/5xx → process is alive and listening + ready = True + break + except Exception as exc: + last_err = str(exc) + # Fallback: bare TCP connect + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.5): + ready = True + break + except OSError: + await asyncio.sleep(0.2) + + if not ready: + try: + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=2) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + except ProcessLookupError: + pass + tail = "" + try: + tail = log_path.read_text(errors="replace")[-2000:] + except OSError: + pass + return ( + f"Error: backend did not become ready on :{port} within " + f"{health_timeout}s (last error: {last_err}).\nLog tail:\n{tail}" + ) + + tracked[slug] = {"proc": proc, "port": port, "pid": proc.pid, "log_path": str(log_path)} + store.update(slug, port=port) + + return json.dumps( + { + "slug": slug, + "port": port, + "pid": proc.pid, + "url": f"http://127.0.0.1:{port}", + "log_path": str(log_path), + }, + indent=2, + ) + + async def handle_list_artifacts(session: "ChatSession", tc_input: dict) -> str: """List every artifact in the workspace, newest first. From 4d302f87f56fb7b990cfbf7be6729027044e7453 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Fri, 15 May 2026 14:32:08 +0300 Subject: [PATCH 11/37] del "serve" action --- anton/core/tools/tool_defs.py | 3 +-- anton/core/tools/tool_handlers.py | 34 ------------------------------- 2 files changed, 1 insertion(+), 36 deletions(-) diff --git a/anton/core/tools/tool_defs.py b/anton/core/tools/tool_defs.py index 72a4c371..17449a43 100644 --- a/anton/core/tools/tool_defs.py +++ b/anton/core/tools/tool_defs.py @@ -33,7 +33,6 @@ class ToolDef: "and data persist across cells — like a notebook you drive programmatically.\n\n" "Actions:\n" "- exec: Run code in the scratchpad (creates it if needed)\n" - "- serve: Start a long-running process (web server, background task) without blocking. Returns immediately.\n" "- view: See all cells and their outputs\n" "- reset: Restart the process, clearing all state (installed packages survive)\n" "- remove: Kill the scratchpad and delete its environment\n" @@ -68,7 +67,7 @@ class ToolDef: "properties": { "action": { "type": "string", - "enum": ["exec", "serve", "view", "reset", "remove", "dump", "install"], + "enum": ["exec", "view", "reset", "remove", "dump", "install"], }, "name": {"type": "string", "description": "Scratchpad name"}, "code": { diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index 73795025..fe9d9185 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -497,8 +497,6 @@ async def _encode_bg(cortex, entries): async def handle_scratchpad(session: ChatSession, tc_input: dict) -> str: """Dispatch a scratchpad tool call by action.""" - import asyncio - action = tc_input.get("action", "") name = tc_input.get("name", "") @@ -538,38 +536,6 @@ async def handle_scratchpad(session: ChatSession, tc_input: dict) -> str: await _fire_post_execute(session, cell) return format_cell_result(cell) - elif action == "serve": - result = await prepare_scratchpad_exec(session, tc_input) - if isinstance(result, str): - return result - pad, code, description, estimated_time, estimated_seconds = result - - prelim_cell = Cell( - code=code, - stdout="", - stderr="", - error=None, - description=description, - estimated_time=estimated_time or str(estimated_seconds), - ) - await _fire_pre_execute(session, prelim_cell) - - async def _run_background(): - cell = await pad.execute( - code, - description=description, - estimated_time=estimated_time, - estimated_seconds=estimated_seconds, - ) - if cell is not None: - session._record_cell_explainability( - pad_name=name, description=description, cell=cell, - ) - await _fire_post_execute(session, cell) - - asyncio.create_task(_run_background()) - return f"Background server '{name}' started. Process running in the scratchpad." - elif action == "view": # get_or_create: new ChatSession has empty _pads but replayed cells on the # manager — same hydration path as exec so view works on the first tool call. From 65268cafeb023b90b0197e6660622ad9f83f51f4 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Fri, 15 May 2026 15:01:24 +0300 Subject: [PATCH 12/37] install dependencies in backend scratchpads --- anton/core/llm/prompts.py | 20 ++++++++++++++++---- anton/core/tools/tool_defs.py | 7 +++++++ anton/core/tools/tool_handlers.py | 31 ++++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index ac900733..745141a0 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -451,14 +451,21 @@ - If the answer to any question is "no" — go back to step 2 and revise the technical \ specification based on what you learned about the actual data -4. IMPLEMENT BACKEND: In a dedicated scratchpad, implement the backend code: +4. IMPLEMENT BACKEND: In a scratchpad **named exactly the artifact slug** \ +(use the `slug` returned by `create_artifact` / `open_artifact` as the scratchpad \ +name), implement the backend code. `launch_backend` runs the backend in this same \ +scratchpad's venv, so any packages you install or imports you test here will be \ +present at launch. - Write the complete backend application (http.server, Bottle, Flask, FastAPI, etc.) - Save it to `/backend.py`, where `` is the folder \ path returned by `create_artifact` in step 1 - If the backend uses any non-stdlib libraries (Bottle, Flask, FastAPI, requests, \ -pandas, etc.), save a `requirements.txt` in the same directory listing them. \ -If the backend uses ONLY the Python standard library (http.server, json, sqlite3, etc.), \ -do NOT create requirements.txt. +pandas, etc.), save a `requirements.txt` in the same directory listing them — \ +one package spec per line (`pkg` or `pkg==1.2`). `launch_backend` reads this file \ +and installs everything into the slug-named scratchpad before spawning the process. \ +Note: only simple lines are supported — `-r`, `-e`, `--index-url` are ignored, as \ +are blank lines and `#` comments. If the backend uses ONLY the Python standard \ +library (http.server, json, sqlite3, etc.), do NOT create requirements.txt. - The backend MUST accept `--port` via argparse and bind to that port. \ NEVER hardcode the port — `launch_backend` picks a free one and passes it in. - The backend serves the frontend at `/` (single-origin, no CORS for stateless backends) @@ -479,6 +486,11 @@ `python backend.py --port ` as a standalone process with `` as cwd, \ waits for readiness, writes the port into `metadata.json`, and returns \ `{{slug, port, pid, url, log_path}}` as JSON. + - Uses the scratchpad named `` — created automatically on first call. If \ +`/requirements.txt` exists, its packages are installed into that \ +scratchpad's venv before spawn (install output is appended to `backend.log` with a \ +banner). An install failure aborts the launch and is returned as an error string — \ +fix `requirements.txt` and retry. - Backend stdout/stderr stream to `/backend.log` — read it if \ the launch fails or the API misbehaves. - Do NOT call `update_artifact(port=...)` manually — `launch_backend` does it. diff --git a/anton/core/tools/tool_defs.py b/anton/core/tools/tool_defs.py index 17449a43..cb07891b 100644 --- a/anton/core/tools/tool_defs.py +++ b/anton/core/tools/tool_defs.py @@ -285,6 +285,13 @@ class ToolDef: "(plus any `extra_args`), waits until the server is reachable, " "records the port in the artifact's `metadata.json`, and returns " "`{slug, port, pid, url, log_path}` as JSON.\n\n" + "Runs in a scratchpad named exactly `` (created on first call). " + "If `/requirements.txt` exists, its package lines are " + "installed into that scratchpad's venv before spawn — install output " + "appended to `backend.log`, install failures abort the launch and are " + "returned as an error string. Only simple lines are supported " + "(`pkg` / `pkg==1.2`); blank lines, `#` comments, and `-`-prefixed " + "flags (`-r`, `-e`, `--index-url`) are ignored.\n\n" "Idempotent: a second call with the same slug terminates the " "previously-launched backend before starting a new one.\n\n" "Requirements on the backend script:\n" diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index fe9d9185..a739789e 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -203,7 +203,7 @@ async def handle_launch_backend(session: "ChatSession", tc_input: dict) -> str: except (TypeError, ValueError): return "Error: `health_timeout` must be a number." - venv_python = await session._scratchpads.venv_python() + venv_python = await session._scratchpads.venv_python(slug) if not venv_python: return ( "Error: scratchpad venv Python is not available. " @@ -211,6 +211,35 @@ async def handle_launch_backend(session: "ChatSession", tc_input: dict) -> str: "has run yet to provision the venv." ) + req_path = folder / "requirements.txt" + if req_path.is_file(): + packages: list[str] = [] + for raw_line in req_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.split("#", 1)[0].strip() + if not line or line.startswith("-"): + continue + packages.append(line) + if packages: + from datetime import datetime, timezone + + pad = await session._scratchpads.get_or_create(slug) + install_result = await pad.install_packages(packages) + banner = ( + f"\n=== requirements.txt install " + f"({datetime.now(timezone.utc).isoformat(timespec='seconds')}) ===\n" + ) + with open(folder / "backend.log", "ab", buffering=0) as install_log: + install_log.write(banner.encode("utf-8")) + install_log.write(install_result.encode("utf-8")) + install_log.write(b"\n") + if install_result.startswith("Install failed") or install_result.startswith( + "Install timed out" + ): + return ( + "Error: dependency install failed for `requirements.txt`.\n" + + install_result + ) + tracked = getattr(session, "_tracked_backends", None) if tracked is None: tracked = {} From 8aec81ffd61800a039c5af2b32fbc0707b8377e7 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Fri, 15 May 2026 17:14:54 +0300 Subject: [PATCH 13/37] split 'launch_backned' tool --- anton/core/backends/local.py | 14 ++ anton/core/tools/tool_handlers.py | 222 ++++-------------------------- 2 files changed, 39 insertions(+), 197 deletions(-) diff --git a/anton/core/backends/local.py b/anton/core/backends/local.py index 67a7010c..411d26b1 100644 --- a/anton/core/backends/local.py +++ b/anton/core/backends/local.py @@ -172,6 +172,20 @@ def venv_python(self) -> str | None: return self._venv_python return None + def ensure_venv(self) -> str | None: + """Provision the venv on disk (recycle if present, create if not) and + return its python interpreter path. + + Public counterpart to the internal `_ensure_venv` used by `start()` + and `install_packages`. Exposed for callers that need only the venv + — not the full runtime sidecar — to spawn auxiliary processes + (e.g. cowork's artifact backend relaunch). Cheap when the venv + already exists; falls back to a fresh `uv venv` / `python -m venv` + otherwise. + """ + self._ensure_venv() + return self.venv_python() + def _verify_venv_python(self) -> bool: if self._venv_python is None: return False diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index a739789e..64e1e432 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -153,23 +153,19 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict async def handle_launch_backend(session: "ChatSession", tc_input: dict) -> str: """Launch the artifact's backend script as a standalone subprocess. - Picks a free TCP port, runs - ` --port [...extra_args]` - with the artifact folder as cwd, waits for the server to become - reachable, persists the port in metadata.json, and tracks the - process on the session so it can be reaped on close. - - Idempotent: a fresh call for the same slug terminates any - previously-tracked backend before launching the new one. + Thin wrapper over `launch_artifact_backend`: validates tool-call shape, + resolves the artifact folder via the session's ArtifactStore, hands + the session's scratchpad pool + tracked-backends dict to the helper, + then persists the discovered port into metadata.json. + + The actual subprocess lifecycle (free-port discovery, dependency + install, health probe, idempotent reap) lives in + `anton.core.artifacts.backend_launcher.launch_artifact_backend` so + other entry points (e.g. cowork's auto-relaunch) can reuse it. """ - import asyncio import json - import os - import signal - import socket - import sys - import urllib.error - import urllib.request + + from anton.core.artifacts.backend_launcher import launch_artifact_backend store = _artifact_store(session) if store is None: @@ -182,203 +178,35 @@ async def handle_launch_backend(session: "ChatSession", tc_input: dict) -> str: if artifact is None: return f"Error: no artifact found for slug `{slug}`." - folder = store.folder_for(slug) rel_path = (tc_input.get("path") or "backend.py").strip() - script = (folder / rel_path).resolve() - try: - script.relative_to(folder.resolve()) - except ValueError: - return f"Error: `path` must stay within the artifact folder ({folder})." - if not script.is_file(): - return f"Error: backend script not found at {script}." - extra_args = tc_input.get("extra_args") or [] - if not isinstance(extra_args, list) or not all(isinstance(x, str) for x in extra_args): - return "Error: `extra_args` must be a list of strings." health_path = tc_input.get("health_path") or "/" - if not health_path.startswith("/"): - health_path = "/" + health_path try: health_timeout = float(tc_input.get("health_timeout", 10)) except (TypeError, ValueError): return "Error: `health_timeout` must be a number." - venv_python = await session._scratchpads.venv_python(slug) - if not venv_python: - return ( - "Error: scratchpad venv Python is not available. " - "This usually means the runtime is remote, or no scratchpad cell " - "has run yet to provision the venv." - ) - - req_path = folder / "requirements.txt" - if req_path.is_file(): - packages: list[str] = [] - for raw_line in req_path.read_text(encoding="utf-8").splitlines(): - line = raw_line.split("#", 1)[0].strip() - if not line or line.startswith("-"): - continue - packages.append(line) - if packages: - from datetime import datetime, timezone - - pad = await session._scratchpads.get_or_create(slug) - install_result = await pad.install_packages(packages) - banner = ( - f"\n=== requirements.txt install " - f"({datetime.now(timezone.utc).isoformat(timespec='seconds')}) ===\n" - ) - with open(folder / "backend.log", "ab", buffering=0) as install_log: - install_log.write(banner.encode("utf-8")) - install_log.write(install_result.encode("utf-8")) - install_log.write(b"\n") - if install_result.startswith("Install failed") or install_result.startswith( - "Install timed out" - ): - return ( - "Error: dependency install failed for `requirements.txt`.\n" - + install_result - ) - tracked = getattr(session, "_tracked_backends", None) if tracked is None: tracked = {} session._tracked_backends = tracked - # Reap any previously-tracked backend for this slug before launching - # the new one — keeps the call idempotent across hot reloads. - prev = tracked.pop(slug, None) - if prev is not None: - prev_proc = prev.get("proc") - if prev_proc is not None and prev_proc.returncode is None: - try: - prev_proc.terminate() - try: - await asyncio.wait_for(prev_proc.wait(), timeout=3) - except asyncio.TimeoutError: - prev_proc.kill() - await prev_proc.wait() - except ProcessLookupError: - pass - - # Bind-and-close to discover a free port. There is a TOCTOU window - # before the backend picks it up — acceptable in single-user dev. - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - port = s.getsockname()[1] - - cmd = [venv_python, str(script), "--port", str(port), *extra_args] - log_path = folder / "backend.log" - log_fd = open(log_path, "ab", buffering=0) - - # PR_SET_PDEATHSIG so the backend dies with Anton on Linux. macOS - # has no equivalent; we rely on close() to reap there. - preexec_fn = None - if sys.platform.startswith("linux"): - def _set_pdeathsig() -> None: - try: - import ctypes - - libc = ctypes.CDLL("libc.so.6", use_errno=True) - PR_SET_PDEATHSIG = 1 - libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM, 0, 0, 0) - except Exception: - pass - - preexec_fn = _set_pdeathsig - - try: - proc = await asyncio.create_subprocess_exec( - *cmd, - cwd=str(folder), - stdout=log_fd, - stderr=log_fd, - stdin=asyncio.subprocess.DEVNULL, - preexec_fn=preexec_fn, - env={**os.environ}, - ) - except OSError as exc: - log_fd.close() - return f"Error: failed to spawn backend: {exc}" - finally: - # The subprocess holds its own dup of the fd; we can close ours. - try: - log_fd.close() - except OSError: - pass - - # Readiness — try HTTP first, fall back to TCP-connect. HTTP 4xx - # still counts as "process is alive and answering" → ready. - loop = asyncio.get_event_loop() - deadline = loop.time() + health_timeout - ready = False - last_err: str | None = None - while loop.time() < deadline: - if proc.returncode is not None: - tail = "" - try: - tail = log_path.read_text(errors="replace")[-2000:] - except OSError: - pass - return ( - f"Error: backend exited early (rc={proc.returncode}) before " - f"binding to :{port}.\nLog tail:\n{tail}" - ) - url = f"http://127.0.0.1:{port}{health_path}" - try: - await asyncio.wait_for( - loop.run_in_executor( - None, lambda: urllib.request.urlopen(url, timeout=1).close() - ), - timeout=1.5, - ) - ready = True - break - except urllib.error.HTTPError: - # 4xx/5xx → process is alive and listening - ready = True - break - except Exception as exc: - last_err = str(exc) - # Fallback: bare TCP connect - try: - with socket.create_connection(("127.0.0.1", port), timeout=0.5): - ready = True - break - except OSError: - await asyncio.sleep(0.2) - - if not ready: - try: - proc.terminate() - try: - await asyncio.wait_for(proc.wait(), timeout=2) - except asyncio.TimeoutError: - proc.kill() - await proc.wait() - except ProcessLookupError: - pass - tail = "" - try: - tail = log_path.read_text(errors="replace")[-2000:] - except OSError: - pass - return ( - f"Error: backend did not become ready on :{port} within " - f"{health_timeout}s (last error: {last_err}).\nLog tail:\n{tail}" - ) - - tracked[slug] = {"proc": proc, "port": port, "pid": proc.pid, "log_path": str(log_path)} - store.update(slug, port=port) + result = await launch_artifact_backend( + slug=slug, + artifact_folder=store.folder_for(slug), + scratchpad_pool=session._scratchpads, + tracked_backends=tracked, + path=rel_path, + extra_args=extra_args, + health_path=health_path, + health_timeout=health_timeout, + ) + if isinstance(result, str): + return result + store.update(slug, port=result["port"]) return json.dumps( - { - "slug": slug, - "port": port, - "pid": proc.pid, - "url": f"http://127.0.0.1:{port}", - "log_path": str(log_path), - }, + {k: v for k, v in result.items() if k != "proc"}, indent=2, ) From 4607740aa7514372d8cdea8471ba7aab48f8fb1d Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Fri, 22 May 2026 17:01:58 +0300 Subject: [PATCH 14/37] prompt update --- anton/core/llm/prompt_builder.py | 2 + anton/core/llm/prompts.py | 79 ++++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/anton/core/llm/prompt_builder.py b/anton/core/llm/prompt_builder.py index 58991335..9d50a80c 100644 --- a/anton/core/llm/prompt_builder.py +++ b/anton/core/llm/prompt_builder.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING from .prompts import ( + ARTIFACTS_PROMPT, BASE_VISUALIZATIONS_PROMPT, BACKEND_GENERATION_PROMPT, CHAT_SYSTEM_PROMPT, @@ -147,6 +148,7 @@ def build( prompt += CHAT_SYSTEM_PROMPT.format( runtime_context=system_prompt_context.runtime_context, + artifacts_section=ARTIFACTS_PROMPT, visualizations_section=visualizations_section, current_datetime=current_datetime, ) diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 745141a0..bca536cb 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -152,6 +152,8 @@ - Host Python packages are available by default. Use the scratchpad install action to \ add more — installed packages persist across resets. +{artifacts_section} + {visualizations_section} CONVERSATION DISCIPLINE (critical): @@ -204,6 +206,50 @@ Only encode genuinely reusable knowledge — not transient conversation details. """ +# --------------------------------------------------------------------------- +# Artifact contract — universal entry point for any user-facing output +# --------------------------------------------------------------------------- + +ARTIFACTS_PROMPT = """\ +ARTIFACTS (applies to all user-facing output): +Any file you create that the user is meant to open, view, download, or run \ +is an ARTIFACT. Artifacts MUST be registered with `create_artifact` BEFORE \ +any file is written. The tool claims a dedicated folder under \ +`/artifacts//`, writes `metadata.json` + `README.md` for you, \ +and returns the absolute folder path. Write ALL of the artifact's files into \ +that returned path. + +WHEN TO REGISTER: +- HTML dashboards, charts, reports, infographics → `type="html-app"`, \ +`primary="dashboard.html"` (or whichever filename you'll use). +- Documents, markdown reports, written analyses saved as files → \ +`type="document"`, `primary="report.md"` (or `.pdf`, `.docx`, …). +- Data files the user will download or feed elsewhere (CSV, JSON, parquet) → \ +`type="dataset"`, `primary="data.csv"`. +- Generated images (PNG, SVG, etc.) → `type="image"`, `primary="chart.png"`. +- Self-contained HTML + JS + CSS apps that run with no backend → \ +`type="fullstack-stateless-app"`, `primary="index.html"`. +- Web apps that need a backend process (see BACKEND & FULLSTACK section) → \ +`type="fullstack-stateful-app"`, `primary="index.html"`. + +WHEN NOT TO REGISTER: +- Pure chat answers, tables, or markdown rendered inline in the conversation \ +(nothing is being saved to disk for the user). +- Internal scratchpad-only files used for computation that the user never \ +opens (intermediate CSVs, cached JSON, debug logs). +- Throwaway files inside the scratchpad's own working directory. + +WORKFLOW: +1. NEW artifact: call `create_artifact(name, description, type, primary?)` \ +→ use the returned `` for every subsequent write. +2. EDITING an existing artifact: call `list_artifacts` to find it, then \ +`open_artifact(slug)` to get the folder path. Do NOT call `create_artifact` \ +again — that creates a duplicate. +3. If you discover the entry-point filename only later (or change it), call \ +`update_artifact(slug, primary=...)` so the renderer opens the right file. +""" + + # --------------------------------------------------------------------------- # Visualization prompt variants — selected by ANTON_PROACTIVE_DASHBOARDS flag # --------------------------------------------------------------------------- @@ -243,9 +289,14 @@ BUILD THE DASHBOARD — use multiple scratchpad cells, but produce ONE single self-contained HTML file: - CRITICAL: The final dashboard MUST be a single .html file with ALL data, CSS, and JS inlined. \ -Do NOT reference external local files (like data.js) — browsers block local file:// cross-references \ -for security reasons and the dashboard will silently fail to load data. +Before the first write, call `create_artifact(type="html-app", \ +name=..., description=..., primary="dashboard.html")` and use the returned \ +`` for every file you write (the HTML, any sibling data files, \ +images, etc.). All paths below referring to "the output directory" mean \ +``. The final dashboard MUST be a single .html file with ALL \ +data, CSS, and JS inlined. Do NOT reference external local files (like \ +data.js) — browsers block local file:// cross-references for security \ +reasons and the dashboard will silently fail to load data. REROUND DISCIPLINE (critical — most "round-cap exhaustion" failures we've \ seen on real dashboards come from drifting off one or more of these): @@ -320,7 +371,6 @@ Output format: - Unless the user explicitly asks for a different format, always output visualizations \ as polished, single-file HTML pages — never raw PNGs or bare image files. -Save output to `{output_dir}` (create it if needed). Visual design: - Make it look good by default. Use a dark theme (#0d1117 background, #e6edf3 text), \ @@ -405,7 +455,11 @@ - For large datasets, summarize the top N and offer to show more. - When the user EXPLICITLY asks for a chart, dashboard, plot, or HTML visualization, \ THEN build it as a self-contained HTML file with inlined CSS, JS, and data. \ -Save output to `{output_dir}` (create it if needed). +Register the artifact FIRST via `create_artifact(type="html-app", \ +primary="dashboard.html", ...)` and write into the returned `` — \ +see the ARTIFACTS section above for the full contract. \ +Fallback only if `create_artifact` is unavailable: save to `{output_dir}` \ +(create it if needed). \ Use Apache ECharts (CDN), dark theme (#0d1117), and follow standard dashboard best practices. \ If the dataset is very large (>100KB), write it to a separate .js file in the same directory. \ Never split CSS or chart logic into separate files — only large data payloads.\ @@ -418,15 +472,12 @@ When the user asks to build a backend service, web application with a backend, or stateless \ API-driven system, follow this workflow: -1. REGISTER THE ARTIFACT: Call the `create_artifact` tool BEFORE creating any files. \ -This creates the folder, `metadata.json`, and `README.md` automatically and returns the \ -absolute folder path. Use that path for ALL subsequent file writes. - - `name`: short human-readable app name (e.g. "Weather Dashboard") - - `description`: one sentence describing what the app does - - `type`: always `"fullstack-stateful-app"` — every app built here requires a backend process - - `primary`: set to `"index.html"` when you know that will be the frontend entry-point - For EDITING an existing app: call `list_artifacts` first to find it, then \ -`open_artifact(slug)` to get the folder path — do NOT call `create_artifact` again. +1. REGISTER THE ARTIFACT: Follow the universal artifact contract from the \ +ARTIFACTS section. For backend apps specifically: + - `type`: `"fullstack-stateful-app"` (every app built here needs a backend process). + - `primary`: set to `"index.html"` when you know that will be the frontend entry-point. + Use the returned `` for ALL subsequent writes (backend.py, \ +index.html, requirements.txt, etc.). 2. TECHNICAL SPECIFICATION (as a system analyst): Create a brief technical specification for \ the application. The specification MUST include: From 4f1f75343454b998409cc63d61213c1406cb0763 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Fri, 22 May 2026 17:07:55 +0300 Subject: [PATCH 15/37] move generated frontend to static folder --- anton/core/llm/prompts.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index bca536cb..9c5491df 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -228,9 +228,11 @@ `type="dataset"`, `primary="data.csv"`. - Generated images (PNG, SVG, etc.) → `type="image"`, `primary="chart.png"`. - Self-contained HTML + JS + CSS apps that run with no backend → \ -`type="fullstack-stateless-app"`, `primary="index.html"`. +`type="fullstack-stateless-app"`, `primary="static/index.html"`. The frontend \ +lives in a `static/` subfolder of the artifact. - Web apps that need a backend process (see BACKEND & FULLSTACK section) → \ -`type="fullstack-stateful-app"`, `primary="index.html"`. +`type="fullstack-stateful-app"`, `primary="static/index.html"`. The frontend \ +lives in a `static/` subfolder of the artifact, served by `backend.py`. WHEN NOT TO REGISTER: - Pure chat answers, tables, or markdown rendered inline in the conversation \ @@ -475,9 +477,11 @@ 1. REGISTER THE ARTIFACT: Follow the universal artifact contract from the \ ARTIFACTS section. For backend apps specifically: - `type`: `"fullstack-stateful-app"` (every app built here needs a backend process). - - `primary`: set to `"index.html"` when you know that will be the frontend entry-point. - Use the returned `` for ALL subsequent writes (backend.py, \ -index.html, requirements.txt, etc.). + - `primary`: set to `"static/index.html"` — the frontend ALWAYS lives in a \ +`static/` subfolder of the artifact (see steps 4 and 5 below). + Use the returned `` for ALL subsequent writes — `backend.py` \ +and `requirements.txt` go directly in `/`; ALL frontend files \ +(HTML, CSS, JS, images, fonts) go into `/static/`. 2. TECHNICAL SPECIFICATION (as a system analyst): Create a brief technical specification for \ the application. The specification MUST include: @@ -519,14 +523,22 @@ library (http.server, json, sqlite3, etc.), do NOT create requirements.txt. - The backend MUST accept `--port` via argparse and bind to that port. \ NEVER hardcode the port — `launch_backend` picks a free one and passes it in. - - The backend serves the frontend at `/` (single-origin, no CORS for stateless backends) + - The backend MUST serve the frontend from the sibling `static/` directory \ +(single-origin, no CORS): `GET /` returns `static/index.html`, and any other \ +non-API path is resolved against `static/` (e.g. `GET /app.css` → \ +`static/app.css`). Compute the static dir relative to the backend file: \ +`STATIC_DIR = Path(__file__).parent / "static"`. - Do NOT start the server inside the scratchpad — use `launch_backend` in step 6. 5. BUILD FRONTEND (if needed): In a separate scratchpad: - Build a single-file HTML dashboard or web interface - Include all CSS and JS inlined (no external file references) - Follow the VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT guidelines - - Save to `/index.html` (or frontend.html) + - Save the entry-point to `/static/index.html` (create the \ +`static/` subfolder if needed). ANY additional frontend assets (separate CSS, \ +JS, images, fonts, large data .js payloads) MUST also live under \ +`/static/` — never at the artifact root, since the backend only \ +serves files from `static/`. - API calls MUST use RELATIVE paths only (e.g. `fetch('/api/items')`, NOT \ `fetch('http://localhost:PORT/api/items')` and NOT any hardcoded base URL). \ The frontend is served by the same backend at `/`, so relative paths resolve to the \ From c6c86b50275dcfaa7c534fb7ac7c785c395d5e93 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Fri, 22 May 2026 17:52:05 +0300 Subject: [PATCH 16/37] record used ds to metadata --- anton/core/artifacts/models.py | 24 ++++++++++++++++++ anton/core/artifacts/store.py | 10 ++++++++ anton/core/llm/prompts.py | 8 ++++++ anton/core/tools/tool_defs.py | 21 +++++++++++++++- anton/core/tools/tool_handlers.py | 41 +++++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/anton/core/artifacts/models.py b/anton/core/artifacts/models.py index 5940dbe5..1dd2d791 100644 --- a/anton/core/artifacts/models.py +++ b/anton/core/artifacts/models.py @@ -69,6 +69,23 @@ class TurnEntry(BaseModel): files_touched: list[str] = Field(default_factory=list) +class DatasourceRef(BaseModel): + """A data-source connection that the artifact's backend reads from. + + Declared by the agent at backend-build time so the metadata can + record which vault connections a fullstack artifact depends on. + Values are derived from the connection slug — `engine` and `name` + match a `~/.anton/data_vault/-` record; `env_prefix` + is the `DS__` token used to namespace the field-level + env vars handed to the backend subprocess. + """ + + slug: str # e.g. "postgres-prod_db" + engine: str # e.g. "postgres" + name: str # e.g. "prod_db" + env_prefix: str # e.g. "DS_POSTGRES_PROD_DB" + + class ProvenanceEntry(BaseModel): """Provenance for a single conversation that contributed to the artifact. @@ -110,6 +127,13 @@ class Artifact(BaseModel): primary: str | None = None port: int | None = None + # ── Agent-declared datasources (fullstack apps) ───────────── + # Connections the backend reads from at runtime. Agent-supplied + # via `update_artifact(datasources=[...])` — typically right + # after writing `backend.py`, so the metadata stays in sync with + # the env-var references in the code. + datasources: list[DatasourceRef] = Field(default_factory=list) + # ── Server-managed contents ───────────────────────────────── files: list[FileEntry] = Field(default_factory=list) provenance: list[ProvenanceEntry] = Field(default_factory=list) diff --git a/anton/core/artifacts/store.py b/anton/core/artifacts/store.py index 4553622a..f9f7ea2f 100644 --- a/anton/core/artifacts/store.py +++ b/anton/core/artifacts/store.py @@ -25,6 +25,7 @@ from anton.core.artifacts.models import ( Artifact, ArtifactType, + DatasourceRef, FileEntry, ProvenanceEntry, TurnEntry, @@ -181,12 +182,14 @@ def update( *, primary: str | None = _UNSET, # type: ignore[assignment] port: int | None = _UNSET, # type: ignore[assignment] + datasources: list[DatasourceRef] | None = _UNSET, # type: ignore[assignment] ) -> Artifact | None: """Update mutable agent-supplied fields on an existing artifact. Only fields explicitly passed are modified; omitted fields are left unchanged. Pass `primary=None` or `primary=""` to clear the entry-point pointer. Pass `port=None` to clear the port. + Pass `datasources=[]` to clear the datasource list. Returns the updated artifact, or None when the slug is missing. """ artifact = self._load_silent(slug) @@ -198,6 +201,8 @@ def update( ) if port is not _UNSET: artifact.port = int(port) if port is not None else None + if datasources is not _UNSET: + artifact.datasources = list(datasources or []) artifact.updatedAt = _utc_now() self._save(artifact) return artifact @@ -368,6 +373,11 @@ def _render_readme_text(artifact: Artifact) -> str: size_kb = max(1, round(f.bytes / 1024)) lines.append(f"- `{f.path}` ({size_kb} KB)") lines.append("") + if artifact.datasources: + lines.append("## Data sources") + for d in artifact.datasources: + lines.append(f"- `{d.slug}` ({d.engine}) — env prefix `{d.env_prefix}`") + lines.append("") if artifact.provenance: lines.append("## Provenance") for entry in artifact.provenance: diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 9c5491df..990d4a0f 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -529,6 +529,14 @@ `static/app.css`). Compute the static dir relative to the backend file: \ `STATIC_DIR = Path(__file__).parent / "static"`. - Do NOT start the server inside the scratchpad — use `launch_backend` in step 6. + - DECLARE DATASOURCES: if `backend.py` reads any `DS____` \ +env var, call `update_artifact(slug=, datasources=[...])` immediately \ +after writing the file. Pass a flat list of connection slugs (e.g. \ +`["postgres-prod_db", "hubspot-main"]`); each slug MUST match a connection \ +from the `Connected Data Sources` section of this prompt. This records the \ +deployable's credential dependencies in `metadata.json` so the artifact can \ +be redeployed with the right env vars later. Skip this call only when the \ +backend uses no `DS_*` vars at all. 5. BUILD FRONTEND (if needed): In a separate scratchpad: - Build a single-file HTML dashboard or web interface diff --git a/anton/core/tools/tool_defs.py b/anton/core/tools/tool_defs.py index 9bcfb148..2314b66e 100644 --- a/anton/core/tools/tool_defs.py +++ b/anton/core/tools/tool_defs.py @@ -213,7 +213,14 @@ class ToolDef: "Pass empty string to clear (renderer reverts to heuristic: " "`index.html` → newest `.html` → newest non-housekeeping file).\n" "- `port`: port the backend process is listening on (fullstack-stateful-app only). " - "Set this after the server confirms it is up." + "Set this after the server confirms it is up.\n" + "- `datasources`: list of vault-connection slugs the artifact's backend " + "reads from (e.g. `[\"postgres-prod_db\", \"hubspot-main\"]`). REQUIRED " + "for `fullstack-stateful-app` whose `backend.py` references any `DS_*` " + "env var — declare it right after writing `backend.py` so metadata.json " + "captures which connections the deployable depends on. Slugs must match " + "existing vault connections (see `Connected Data Sources` in the system " + "prompt). Pass `[]` to clear." ), input_schema={ "type": "object", @@ -230,6 +237,18 @@ class ToolDef: "type": "integer", "description": "Port number the backend process is listening on.", }, + "datasources": { + "type": "array", + "description": ( + "Vault-connection slugs the backend reads from. Replaces " + "the existing list — pass the full set every time. Use " + "`[]` to clear." + ), + "items": { + "type": "string", + "description": "Connection slug, e.g. \"postgres-prod_db\".", + }, + }, }, "required": ["slug"], }, diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index 801440b8..743d080e 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -123,6 +123,8 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict Only fields present in the input are modified. Supports: - `primary`: entry-point file path (empty string to clear) - `port`: backend port number (fullstack-stateful-app only) + - `datasources`: list of vault-connection slugs the backend reads from. + `engine`, `name`, and `env_prefix` are derived from the vault. """ import json @@ -140,6 +142,44 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict if "port" in tc_input: kwargs["port"] = tc_input["port"] + if "datasources" in tc_input: + from anton.core.artifacts.models import DatasourceRef + from anton.core.datasources.data_vault import LocalDataVault, _slug_env_prefix + + raw_list = tc_input.get("datasources") or [] + if not isinstance(raw_list, list): + return "Error: `datasources` must be a list of slug strings." + + vault = session._data_vault or LocalDataVault() + known = {f"{c['engine']}-{c['name']}": (c["engine"], c["name"]) + for c in vault.list_connections()} + + refs: list[DatasourceRef] = [] + unknown: list[str] = [] + for item in raw_list: + if not isinstance(item, str): + return "Error: each entry in `datasources` must be a slug string." + ref_slug = item.strip() + if not ref_slug: + continue + if ref_slug not in known: + unknown.append(ref_slug) + continue + engine, name = known[ref_slug] + refs.append(DatasourceRef( + slug=ref_slug, + engine=engine, + name=name, + env_prefix=_slug_env_prefix(engine, name), + )) + if unknown: + return ( + f"Error: unknown datasource slug(s): {', '.join(unknown)}. " + f"Each slug must match an existing vault connection " + f"(format: `-`)." + ) + kwargs["datasources"] = refs + artifact = store.update(slug, **kwargs) if artifact is None: return f"Error: no artifact found for slug `{slug}`." @@ -147,6 +187,7 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict "slug": artifact.slug, "primary": artifact.primary, "port": artifact.port, + "datasources": [d.model_dump() for d in artifact.datasources], }, indent=2) From fbb24320472a0123de5a425e2e6215365872bd15 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Fri, 22 May 2026 17:54:45 +0300 Subject: [PATCH 17/37] backend_launch --- anton/core/artifacts/backend_launcher.py | 255 +++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 anton/core/artifacts/backend_launcher.py diff --git a/anton/core/artifacts/backend_launcher.py b/anton/core/artifacts/backend_launcher.py new file mode 100644 index 00000000..d3c456e3 --- /dev/null +++ b/anton/core/artifacts/backend_launcher.py @@ -0,0 +1,255 @@ +"""Launch a fullstack artifact's backend script as a standalone subprocess. + +Extracted from `anton/core/tools/tool_handlers.handle_launch_backend` so it +can be invoked outside of a ChatSession — notably from cowork, which +auto-relaunches backends when the user opens a preview after the Anton +session that created them has ended. + +The helper owns: requirements.txt install into the scratchpad venv, free +port discovery, subprocess spawn with PR_SET_PDEATHSIG on Linux, HTTP/TCP +readiness probe, and idempotent reaping of any previously-tracked process +for the same slug. It does NOT own: artifact metadata writes (caller +updates `metadata.json.port` if appropriate), `--port`-flag protocol on +the backend script (assumed; callers wanting a different protocol should +build their own launcher). +""" +from __future__ import annotations + +import asyncio +import os +import signal +import socket +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Protocol + + +class ScratchpadPoolLike(Protocol): + """Minimal surface the launcher needs from a scratchpad pool. + + Both `anton.core.backends.ScratchpadManager` and cowork's module-level + pool wrapper satisfy this — the launcher stays decoupled from either + concrete implementation. + """ + + async def venv_python(self, name: str) -> str | None: ... + + async def get_or_create(self, name: str) -> Any: ... + # The returned object must expose: + # async install_packages(packages: list[str]) -> str + + +async def launch_artifact_backend( + *, + slug: str, + artifact_folder: Path, + scratchpad_pool: ScratchpadPoolLike, + tracked_backends: dict[str, dict], + path: str = "backend.py", + extra_args: list[str] | None = None, + health_path: str = "/", + health_timeout: float = 10.0, +) -> dict | str: + """Launch the artifact's backend script in its scratchpad venv. + + Returns a dict `{slug, port, pid, url, log_path, proc}` on success + (caller is responsible for persisting `port` to artifact metadata if + needed). Returns an error string on failure — the prefix tells the + caller whether the failure is in script resolution, dependency + install, or runtime readiness. + + `tracked_backends` is a dict the caller owns; the launcher stores the + spawned `asyncio.subprocess.Process` under `slug` and reaps any + previously-tracked process for the same slug before spawning. The + caller is responsible for cleaning the dict on shutdown. + """ + extra_args = list(extra_args or []) + folder = artifact_folder + + script = (folder / path).resolve() + try: + script.relative_to(folder.resolve()) + except ValueError: + return f"Error: `path` must stay within the artifact folder ({folder})." + if not script.is_file(): + return f"Error: backend script not found at {script}." + + if not isinstance(extra_args, list) or not all(isinstance(x, str) for x in extra_args): + return "Error: `extra_args` must be a list of strings." + if not health_path.startswith("/"): + health_path = "/" + health_path + + venv_python = await scratchpad_pool.venv_python(slug) + if not venv_python: + return ( + "Error: scratchpad venv Python is not available. " + "This usually means the runtime is remote, or no scratchpad cell " + "has run yet to provision the venv." + ) + + req_path = folder / "requirements.txt" + if req_path.is_file(): + packages: list[str] = [] + for raw_line in req_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.split("#", 1)[0].strip() + if not line or line.startswith("-"): + continue + packages.append(line) + if packages: + from datetime import datetime, timezone + + pad = await scratchpad_pool.get_or_create(slug) + install_result = await pad.install_packages(packages) + banner = ( + f"\n=== requirements.txt install " + f"({datetime.now(timezone.utc).isoformat(timespec='seconds')}) ===\n" + ) + with open(folder / "backend.log", "ab", buffering=0) as install_log: + install_log.write(banner.encode("utf-8")) + install_log.write(install_result.encode("utf-8")) + install_log.write(b"\n") + if install_result.startswith("Install failed") or install_result.startswith( + "Install timed out" + ): + return ( + "Error: dependency install failed for `requirements.txt`.\n" + + install_result + ) + + # Reap any previously-tracked backend for this slug before launching + # the new one — keeps the call idempotent across hot reloads. + prev = tracked_backends.pop(slug, None) + if prev is not None: + prev_proc = prev.get("proc") + if prev_proc is not None and prev_proc.returncode is None: + try: + prev_proc.terminate() + try: + await asyncio.wait_for(prev_proc.wait(), timeout=3) + except asyncio.TimeoutError: + prev_proc.kill() + await prev_proc.wait() + except ProcessLookupError: + pass + + # Bind-and-close to discover a free port. There is a TOCTOU window + # before the backend picks it up — acceptable in single-user dev. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + + cmd = [venv_python, str(script), "--port", str(port), *extra_args] + log_path = folder / "backend.log" + log_fd = open(log_path, "ab", buffering=0) + + # PR_SET_PDEATHSIG so the backend dies with the parent on Linux. macOS + # has no equivalent; we rely on caller-side reap there. + preexec_fn = None + if sys.platform.startswith("linux"): + def _set_pdeathsig() -> None: + try: + import ctypes + + libc = ctypes.CDLL("libc.so.6", use_errno=True) + PR_SET_PDEATHSIG = 1 + libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM, 0, 0, 0) + except Exception: + pass + + preexec_fn = _set_pdeathsig + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + cwd=str(folder), + stdout=log_fd, + stderr=log_fd, + stdin=asyncio.subprocess.DEVNULL, + preexec_fn=preexec_fn, + env={**os.environ}, + ) + except OSError as exc: + log_fd.close() + return f"Error: failed to spawn backend: {exc}" + finally: + try: + log_fd.close() + except OSError: + pass + + # Readiness — try HTTP first, fall back to TCP-connect. HTTP 4xx + # still counts as "process is alive and answering" → ready. + loop = asyncio.get_event_loop() + deadline = loop.time() + health_timeout + ready = False + last_err: str | None = None + while loop.time() < deadline: + if proc.returncode is not None: + tail = "" + try: + tail = log_path.read_text(errors="replace")[-2000:] + except OSError: + pass + return ( + f"Error: backend exited early (rc={proc.returncode}) before " + f"binding to :{port}.\nLog tail:\n{tail}" + ) + url = f"http://127.0.0.1:{port}{health_path}" + try: + await asyncio.wait_for( + loop.run_in_executor( + None, lambda: urllib.request.urlopen(url, timeout=1).close() + ), + timeout=1.5, + ) + ready = True + break + except urllib.error.HTTPError: + ready = True + break + except Exception as exc: + last_err = str(exc) + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.5): + ready = True + break + except OSError: + await asyncio.sleep(0.2) + + if not ready: + try: + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=2) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + except ProcessLookupError: + pass + tail = "" + try: + tail = log_path.read_text(errors="replace")[-2000:] + except OSError: + pass + return ( + f"Error: backend did not become ready on :{port} within " + f"{health_timeout}s (last error: {last_err}).\nLog tail:\n{tail}" + ) + + tracked_backends[slug] = { + "proc": proc, + "port": port, + "pid": proc.pid, + "log_path": str(log_path), + } + + return { + "slug": slug, + "port": port, + "pid": proc.pid, + "url": f"http://127.0.0.1:{port}", + "log_path": str(log_path), + "proc": proc, + } From 0956698ad48c987c736041a8e3fa512ff4ddcbba Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Fri, 22 May 2026 18:25:31 +0300 Subject: [PATCH 18/37] prompt for lambda handler in backend.py --- anton/core/llm/prompts.py | 121 ++++++++++++++++++++++++++++++-------- 1 file changed, 98 insertions(+), 23 deletions(-) diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 990d4a0f..d0d095d8 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -491,10 +491,11 @@ * Endpoints and HTTP methods * Request/response schemas (JSON examples) * Error handling - - Framework choice: PREFER Python's built-in http.server or http module if possible. \ - If that's insufficient, use Bottle (simplest, minimal surface area). \ - Only use FastAPI/Flask if the requirements demand it. - - Key dependencies and libraries needed + - Framework: ALWAYS use FastAPI. No other framework is supported here — \ + every backend MUST be FastAPI so it can be invoked both locally and as \ + an AWS Lambda function via the canonical template in step 4. + - Key dependencies and libraries needed (in addition to the mandatory \ + `fastapi`, `mangum`, `uvicorn` — see step 4) 3. FETCH & VALIDATE SAMPLE DATA: Using the scratchpad tool: - Fetch representative sample data from the user's data source (API, database, file) @@ -511,23 +512,88 @@ name), implement the backend code. `launch_backend` runs the backend in this same \ scratchpad's venv, so any packages you install or imports you test here will be \ present at launch. - - Write the complete backend application (http.server, Bottle, Flask, FastAPI, etc.) - - Save it to `/backend.py`, where `` is the folder \ -path returned by `create_artifact` in step 1 - - If the backend uses any non-stdlib libraries (Bottle, Flask, FastAPI, requests, \ -pandas, etc.), save a `requirements.txt` in the same directory listing them — \ -one package spec per line (`pkg` or `pkg==1.2`). `launch_backend` reads this file \ -and installs everything into the slug-named scratchpad before spawning the process. \ -Note: only simple lines are supported — `-r`, `-e`, `--index-url` are ignored, as \ -are blank lines and `#` comments. If the backend uses ONLY the Python standard \ -library (http.server, json, sqlite3, etc.), do NOT create requirements.txt. + + CANONICAL TEMPLATE (use this skeleton verbatim, add your routes inside the \ +`# === API routes ===` block). It runs unchanged both locally \ +(`python backend.py --port=NNN`) and on AWS Lambda (handler = `backend.handler`): + + ```python + import argparse + from pathlib import Path + from fastapi import FastAPI + from fastapi.middleware.cors import CORSMiddleware + from fastapi.staticfiles import StaticFiles + from mangum import Mangum + + app = FastAPI() + + # CORS — frontend may be served from a different origin (e.g. CloudFront/S3 + # in front of the Lambda). Tighten `allow_origins` in production. + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ) + + # === API routes === + @app.get("/api/hello") + async def hello(): + return {{"hello": "world"}} + + # Static mount MUST come AFTER all API routes (mount at "/" catches every + # remaining path). Used for local preview; in Lambda, statics are served + # by an external service (CloudFront/S3), so this mount is harmless there. + STATIC_DIR = Path(__file__).parent / "static" + if STATIC_DIR.exists(): + app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static") + + # AWS Lambda entry-point. lifespan="off" is REQUIRED — Lambda has no + # long-lived process to run FastAPI's startup/shutdown events on. + handler = Mangum(app, lifespan="off") + + if __name__ == "__main__": + import uvicorn + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, required=True) + args = parser.parse_args() + uvicorn.run(app, host="127.0.0.1", port=args.port) + ``` + + RULES (critical): + - Save the file as `/backend.py` — the filename and the \ +`handler` attribute name are load-bearing (Lambda config points to \ +`backend.handler`). Do NOT rename either. + - Keep `Mangum(app, lifespan="off")`. Without `lifespan="off"` Mangum \ +warns and may fail cold start. + - API routes MUST be registered BEFORE `app.mount("/", StaticFiles(...))`. \ +FastAPI matches in registration order — a mount at `/` swallows everything \ +after it. - The backend MUST accept `--port` via argparse and bind to that port. \ NEVER hardcode the port — `launch_backend` picks a free one and passes it in. - - The backend MUST serve the frontend from the sibling `static/` directory \ -(single-origin, no CORS): `GET /` returns `static/index.html`, and any other \ -non-API path is resolved against `static/` (e.g. `GET /app.css` → \ -`static/app.css`). Compute the static dir relative to the backend file: \ -`STATIC_DIR = Path(__file__).parent / "static"`. + - Prefer `async def` for I/O-bound routes (DB queries, external HTTP \ +calls via `httpx.AsyncClient`). Sync `def` is fine for trivial CPU work, but \ +sync blocking I/O inside an async app stalls the event loop. + - STATELESS: no module-level mutable caches that matter across requests \ +(`USERS = {{}}`, `SESSIONS = []`). In Lambda these globals may or may not \ +survive between invocations — never rely on them. All persistence goes \ +through external data sources. + - FILESYSTEM: assume read-only. The only writable path in Lambda is \ +`/tmp` (ephemeral, 512 MB). Do NOT write to `` at runtime. + - LOGGING: `print()` and `logging.getLogger(__name__).info(...)` both go \ +to CloudWatch in Lambda and to `backend.log` locally — no extra setup needed. + - REQUIREMENTS: always save a `/requirements.txt` with at \ +minimum: + ``` + fastapi + mangum + uvicorn + ``` + Add any other libraries the backend imports (one per line: `pkg` or \ +`pkg==1.2`). `launch_backend` reads this file and installs everything into \ +the slug-named scratchpad's venv before spawning the process. Only simple \ +lines are supported — `-r`, `-e`, `--index-url`, blank lines and `#` \ +comments are ignored. - Do NOT start the server inside the scratchpad — use `launch_backend` in step 6. - DECLARE DATASOURCES: if `backend.py` reads any `DS____` \ env var, call `update_artifact(slug=, datasources=[...])` immediately \ @@ -579,10 +645,19 @@ to browser CORS/file:// restrictions. DEPLOYMENT NOTES: -- Backend must be stateless (no mutable global state that matters across requests) -- All data persistence should go through the user's connected data sources (databases, APIs) -- The backend process shuts down when the Anton CLI session ends (per MVP constraints) -- For production, the user must deploy the backend.py file to their own infrastructure +- Same `backend.py` runs in two modes: + - LOCAL: `python backend.py --port=NNN` (used by `launch_backend`). \ +uvicorn serves the FastAPI app and the `static/` mount, frontend reachable at `/`. + - AWS LAMBDA: Lambda invokes `backend.handler` (Mangum-wrapped ASGI app) \ +per request. Statics are served by an external service (CloudFront/S3), so \ +the `StaticFiles` mount sits unused in Lambda — Lambda only sees `/api/*` \ +traffic via API Gateway / Function URL routing. +- The local backend process shuts down when the Anton CLI session ends (per MVP constraints). +- For production deployment to Lambda, the user packages `backend.py` + \ +`requirements.txt` (and any data files) as a zip or container image, \ +configures `Handler = backend.handler`, and exposes it via API Gateway or \ +Function URL. `DS_*` env vars from `datasources` in `metadata.json` must be \ +set on the Lambda function. PUBLISH OR SHARE: - Publishing is disabled for this MVP (per constraints), but preview is fully supported From 80136441cf6acf8eb015e6efe5c324c573a6058a Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Mon, 25 May 2026 13:51:44 +0300 Subject: [PATCH 19/37] improve scrub_credentials --- anton/utils/datasources.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/anton/utils/datasources.py b/anton/utils/datasources.py index be02789b..2f2e4247 100644 --- a/anton/utils/datasources.py +++ b/anton/utils/datasources.py @@ -91,9 +91,9 @@ def scrub_credentials(text: str) -> str: """ for key in _DS_SECRET_VARS: value = os.environ.get(key, "") - if not value: + if not value or len(value) < 4: continue - text = text.replace(value, f"[{key}]") + text = re.sub(r'(? Date: Mon, 25 May 2026 17:02:12 +0300 Subject: [PATCH 20/37] allow publish fullstack artifacts --- anton/chat.py | 127 ++++++++++++++++++++++++++++++++++----------- anton/publisher.py | 104 +++++++++++++++++++++++++++++++++++-- 2 files changed, 196 insertions(+), 35 deletions(-) diff --git a/anton/chat.py b/anton/chat.py index 3e99f241..8a88f6b8 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -462,34 +462,100 @@ async def _handle_publish( _W(_P.home()).set_secret("ANTON_MINDS_API_KEY", api_key) console.print() - # 2. Find the HTML file to publish + # 2. Find the HTML file or fullstack artifact to publish import re + from anton.core.artifacts import ArtifactStore + from anton.publisher import FULLSTACK_ARTIFACT_TYPES + # Search the new artifacts// tree (recursive — each artifact # owns its own subfolder). The legacy `.anton/output/` flat # directory is no longer scanned; users move old files into a # proper artifact subfolder if they still want them publishable. artifacts_root = Path(settings.artifacts_dir) publish_index_dir = artifacts_root # `.published.json` lives at the root + store = ArtifactStore(artifacts_root) + + def _make_candidate(path: Path) -> tuple[str, Path, str, str] | None: + """Resolve a user-supplied path to (label, target, kind, file_key). + + Returns None when the path isn't publishable. `kind` is "html" or + "fullstack"; `file_key` is the entry used in `.published.json`. + """ + if path.is_dir(): + slug = path.name + artifact = store.open(slug) + if artifact and artifact.type in FULLSTACK_ARTIFACT_TYPES: + return (artifact.name or slug, path, "fullstack", f"{slug}/") + return None + if path.is_file() and path.suffix.lower() in {".html", ".htm"}: + if not _is_publishable_html(path, artifacts_root): + return None + title = _extract_html_title(path, re) + try: + rel_key = path.relative_to(artifacts_root).as_posix() + except ValueError: + rel_key = path.name + return (title or path.name, path, "html", rel_key) + return None if file_arg: - target = Path(file_arg) - if not target.is_absolute(): - target = Path(settings.workspace_path) / file_arg + target_path = Path(file_arg) + if not target_path.is_absolute(): + # Resolve relative to artifacts_root first (so `/publish my-app` works + # when there's an artifact slug), then fall back to workspace_path. + candidate_root = artifacts_root / file_arg + if candidate_root.exists(): + target_path = candidate_root + else: + target_path = Path(settings.workspace_path) / file_arg + + candidate = _make_candidate(target_path) + if candidate is None: + console.print(f" [anton.warning]Not publishable: {target_path}[/]") + console.print() + return + label, target, kind, file_key = candidate else: - # Recursively list publishable HTML files under any artifact, sorted by mtime. + candidates: list[tuple[str, Path, str, str]] = [] + if artifacts_root.is_dir(): - all_html = list(artifacts_root.rglob("*.html")) - html_files = sorted( - [f for f in all_html if _is_publishable_html(f, artifacts_root)], - key=lambda f: f.stat().st_mtime, - reverse=True, - ) - else: - html_files = [] + # Fullstack artifacts — one entry per artifact folder. Collect slugs + # first so the HTML scan below can skip files inside these directories. + fullstack_slugs: set[str] = set() + for child in artifacts_root.iterdir(): + if not child.is_dir(): + continue + artifact = store.open(child.name) + if artifact and artifact.type in FULLSTACK_ARTIFACT_TYPES: + fullstack_slugs.add(child.name) + candidates.append( + (artifact.name or child.name, child, "fullstack", f"{child.name}/") + ) - if not html_files: - console.print(f" [anton.warning]No publishable HTML files found under {artifacts_root}/[/]") + # HTML reports — recursive scan, mtime-sorted. + # Skip files that live inside a fullstack artifact directory (e.g. + # static/index.html) — those are already represented by the entry above. + for f in artifacts_root.rglob("*.html"): + try: + rel = f.relative_to(artifacts_root) + if rel.parts[0] in fullstack_slugs: + continue + except ValueError: + pass + if not _is_publishable_html(f, artifacts_root): + continue + title = _extract_html_title(f, re) + rel_key = f.relative_to(artifacts_root).as_posix() + candidates.append((title or f.name, f, "html", rel_key)) + + candidates.sort( + key=lambda c: c[1].stat().st_mtime if c[1].exists() else 0, + reverse=True, + ) + + if not candidates: + console.print(f" [anton.warning]Nothing publishable under {artifacts_root}/[/]") console.print() return @@ -497,19 +563,21 @@ async def _handle_publish( offset = 0 while True: - page = html_files[offset:offset + PAGE_SIZE] - has_more = offset + PAGE_SIZE < len(html_files) + page = candidates[offset:offset + PAGE_SIZE] + has_more = offset + PAGE_SIZE < len(candidates) console.print(" [anton.cyan]Available reports:[/]") console.print() - for i, f in enumerate(page, offset + 1): - rel_path = f.relative_to(artifacts_root).as_posix() - title = _extract_html_title(f, re) - label = title or f.name - console.print(f" [bold]{i}[/] {label} [anton.muted]{rel_path}[/]") + for i, (lbl, path, kind, _key) in enumerate(page, offset + 1): + try: + rel_path = path.relative_to(artifacts_root).as_posix() + except ValueError: + rel_path = path.name + tag = " [anton.muted][fullstack][/]" if kind == "fullstack" else "" + console.print(f" [bold]{i}[/] {lbl}{tag} [anton.muted]{rel_path}[/]") if has_more: - console.print(f"\n [anton.muted]m Show more ({len(html_files) - offset - PAGE_SIZE} remaining)[/]") + console.print(f"\n [anton.muted]m Show more ({len(candidates) - offset - PAGE_SIZE} remaining)[/]") console.print() choice = await prompt_or_cancel(" Select", default="1") @@ -524,9 +592,9 @@ async def _handle_publish( try: idx = int(choice) - 1 - if idx < 0 or idx >= len(html_files): + if idx < 0 or idx >= len(candidates): raise ValueError - target = html_files[idx] + label, target, kind, file_key = candidates[idx] break except (ValueError, IndexError): console.print(" [anton.warning]Invalid choice.[/]") @@ -534,19 +602,19 @@ async def _handle_publish( return if not target.exists(): - console.print(f" [anton.warning]File not found: {target}[/]") + console.print(f" [anton.warning]Path not found: {target}[/]") console.print() return - # Check if file is publishable - if not _is_publishable_html(target, artifacts_root): + # HTML safety check — fullstack targets are pre-validated via metadata. + if kind == "html" and not _is_publishable_html(target, artifacts_root): console.print(" [anton.error]Cannot publish this HTML file:[/]") console.print(" It is in a directory with Python files (fullstack application).") console.print(" Only standalone HTML reports can be published.") console.print() return - # 3. Check if this file was previously published + # 3. Check if this artifact was previously published published_json = publish_index_dir / ".published.json" published_map = {} try: @@ -556,7 +624,6 @@ async def _handle_publish( pass report_id = None - file_key = target.relative_to(artifacts_root).as_posix() prev = published_map.get(file_key) if prev and prev.get("report_id"): diff --git a/anton/publisher.py b/anton/publisher.py index a978a637..917ae21a 100644 --- a/anton/publisher.py +++ b/anton/publisher.py @@ -10,6 +10,8 @@ import zipfile from pathlib import Path +from anton.core.artifacts.models import Artifact +from anton.core.datasources.data_vault import LocalDataVault from anton.minds_client import minds_request from anton.utils.datasources import scrub_credentials @@ -21,7 +23,13 @@ ) # File extensions treated as text and subject to credential scrubbing. -_TEXT_EXTENSIONS = {".html", ".htm", ".js", ".css"} +_TEXT_EXTENSIONS = {".html", ".htm", ".js", ".css", ".py", ".txt"} + +# Artifact types that ship as a fullstack bundle (backend + static/ + secrets). +FULLSTACK_ARTIFACT_TYPES = frozenset({"fullstack-stateful-app", "fullstack-stateless-app"}) + +# Filenames inside an artifact folder that are housekeeping — never bundled. +_FULLSTACK_EXCLUDED = {"metadata.json", "README.md", "backend.log", ".published.json"} DEFAULT_PUBLISH_URL = "https://4nton.ai" @@ -95,6 +103,73 @@ def _zip_html(path: Path) -> bytes: return buf.getvalue() +def _load_artifact_metadata(artifact_dir: Path) -> Artifact | None: + """Return the parsed Artifact for a directory, or None when no/invalid metadata.""" + meta_path = artifact_dir / "metadata.json" + if not meta_path.is_file(): + return None + try: + return Artifact.model_validate(json.loads(meta_path.read_text(encoding="utf-8"))) + except Exception: + return None + + +def _zip_fullstack(artifact_dir: Path) -> tuple[bytes, list[str]]: + """Bundle backend.py + static/ + requirements.txt into a zip. + + Returns (zip_bytes, included_arcnames). Text files are scrubbed. + Housekeeping files (metadata.json, README.md, backend.log) are excluded. + """ + buf = io.BytesIO() + included: list[str] = [] + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + backend = artifact_dir / "backend.py" + if backend.is_file(): + _write_scrubbed(zf, backend, "backend.py") + included.append("backend.py") + + reqs = artifact_dir / "requirements.txt" + if reqs.is_file(): + _write_scrubbed(zf, reqs, "requirements.txt") + included.append("requirements.txt") + + static_dir = artifact_dir / "static" + if static_dir.is_dir(): + for f in sorted(static_dir.rglob("*")): + if not f.is_file(): + continue + arc_name = f"static/{f.relative_to(static_dir).as_posix()}" + if Path(arc_name).name in _FULLSTACK_EXCLUDED: + continue + _write_scrubbed(zf, f, arc_name) + included.append(arc_name) + return buf.getvalue(), included + + +def _collect_datasource_secrets( + artifact: Artifact, +) -> tuple[dict[str, str], list[str]]: + """Resolve DS_*__FIELD secrets for an artifact's declared datasources. + + Returns (secrets, missing) where `missing` is the list of slugs whose + vault entry could not be loaded. Caller decides how to surface that. + """ + vault = LocalDataVault() + secrets: dict[str, str] = {} + missing: list[str] = [] + for ref in artifact.datasources: + fields = vault.load(ref.engine, ref.name) + if not fields: + missing.append(ref.slug) + continue + for key, value in fields.items(): + if value is None: + continue + env_name = f"{ref.env_prefix}__{key.upper()}" + secrets[env_name] = str(value) + return secrets, missing + + def publish( file_path: Path, *, @@ -103,24 +178,43 @@ def publish( publish_url: str = DEFAULT_PUBLISH_URL, ssl_verify: bool = True, ) -> dict: - """Zip and upload an HTML file/directory. Returns the upload response dict. + """Zip and upload an HTML file/directory or a fullstack artifact directory. + + For fullstack artifacts (metadata.json with type ∈ FULLSTACK_ARTIFACT_TYPES) + bundles backend.py + static/ + requirements.txt and resolves DS_*__FIELD + secrets from the local data vault for each declared datasource. Secrets + travel in the JSON body alongside the zip, not inside it. Args: report_id: If provided, updates an existing report (new version). If None, creates a new report. - Response keys: user_prefix, report_id, md5, view_url, version, files + Response keys (HTML path): user_prefix, report_id, md5, view_url, version, files """ if not file_path.exists(): raise FileNotFoundError(f"Path not found: {file_path}") - zipped = _zip_html(file_path) - payload_dict: dict = {"file_payload": base64.b64encode(zipped).decode()} + payload_dict: dict = {} + + artifact = _load_artifact_metadata(file_path) if file_path.is_dir() else None + if artifact is not None and artifact.type in FULLSTACK_ARTIFACT_TYPES: + zipped, _included = _zip_fullstack(file_path) + secrets, missing = _collect_datasource_secrets(artifact) + payload_dict["artifact_type"] = artifact.type + payload_dict["artifact_id"] = artifact.id + payload_dict["secrets"] = secrets + if missing: + payload_dict["missing_datasources"] = missing + else: + zipped = _zip_html(file_path) + + payload_dict["file_payload"] = base64.b64encode(zipped).decode() if report_id: payload_dict["report_id"] = report_id payload = json.dumps(payload_dict).encode() url = f"{publish_url.rstrip('/')}/upload" + url = "http://127.0.0.1:8765/upload" # for test raw = minds_request(url, api_key, method="POST", payload=payload, verify=ssl_verify) return json.loads(raw) From 6d5428ec20ba15acff389153f772f25b6510ca31 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Mon, 25 May 2026 17:30:41 +0300 Subject: [PATCH 21/37] add api-base to prompt --- anton/core/llm/prompts.py | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index d0d095d8..a6f4008f 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -566,6 +566,13 @@ async def hello(): `backend.handler`). Do NOT rename either. - Keep `Mangum(app, lifespan="off")`. Without `lifespan="off"` Mangum \ warns and may fail cold start. + - ALL API endpoints MUST live under the `/api/*` path prefix (e.g. \ +`/api/items`, `/api/users/{{user_id}}`, `/api/search`). This is a hard \ +contract between backend and frontend: it separates API traffic from the \ +static mount at `/`, and lets edge routing (CloudFront behaviors, API \ +Gateway path-based routing) split frontend vs backend traffic by prefix \ +in production. NEVER expose routes at the root (e.g. `/items`, `/login`) — \ +they will collide with the static mount and break in deployment. - API routes MUST be registered BEFORE `app.mount("/", StaticFiles(...))`. \ FastAPI matches in registration order — a mount at `/` swallows everything \ after it. @@ -613,10 +620,31 @@ async def hello(): JS, images, fonts, large data .js payloads) MUST also live under \ `/static/` — never at the artifact root, since the backend only \ serves files from `static/`. - - API calls MUST use RELATIVE paths only (e.g. `fetch('/api/items')`, NOT \ -`fetch('http://localhost:PORT/api/items')` and NOT any hardcoded base URL). \ -The frontend is served by the same backend at `/`, so relative paths resolve to the \ -correct origin automatically — this keeps the app portable across ports and hosts. + - All backend endpoints MUST be called under the `/api/*` prefix (matches \ +the backend route convention from step 4). The frontend never calls bare \ +paths like `/items` — always `/api/items`. + - API base URL is supplied via a `` tag so the same HTML works \ +locally AND when deployed with frontend and backend on different origins \ +(e.g. CloudFront/S3 + API Gateway/Lambda). Include this line in ``: + ```html + + ``` + Empty `content` is the local default — fetch falls back to a relative \ +path and hits the same FastAPI process that serves the page. At deploy \ +time the publisher rewrites `content=""` to the real API root \ +(e.g. `content="https://abc123.execute-api.us-east-1.amazonaws.com"`). + - Read the meta tag once at startup and prepend it to every API call. \ +Use this exact pattern (or an equivalent helper) — do NOT scatter \ +`document.querySelector` calls across the codebase: + ```js + const API_BASE = document.querySelector('meta[name="api-base"]')?.content || ""; + const api = (path) => `${{API_BASE}}${{path}}`; + // usage: fetch(api('/api/items')) + ``` + - NEVER hardcode an absolute URL in the source — no \ +`fetch('http://localhost:PORT/...')`, no `fetch('https://api.example.com/...')`, \ +no `const API_BASE = 'http://...'`. The meta tag is the ONLY place the \ +base URL is configured. 6. LAUNCH THE BACKEND: Call the `launch_backend` tool with the artifact's slug: - `launch_backend(slug=)` — the tool picks a free port, spawns \ From 5bcf2275149ae48c18612beaa518a21d9a3b2a4f Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Mon, 25 May 2026 18:07:51 +0300 Subject: [PATCH 22/37] del test url --- anton/publisher.py | 1 - 1 file changed, 1 deletion(-) diff --git a/anton/publisher.py b/anton/publisher.py index 917ae21a..f0bb738c 100644 --- a/anton/publisher.py +++ b/anton/publisher.py @@ -214,7 +214,6 @@ def publish( payload = json.dumps(payload_dict).encode() url = f"{publish_url.rstrip('/')}/upload" - url = "http://127.0.0.1:8765/upload" # for test raw = minds_request(url, api_key, method="POST", payload=payload, verify=ssl_verify) return json.loads(raw) From e769b8ef89cb0ecfbbc072dbd272ae3747ee4118 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Wed, 27 May 2026 16:41:36 +0300 Subject: [PATCH 23/37] add python version to post /publish --- anton/publisher.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/anton/publisher.py b/anton/publisher.py index f0bb738c..05c91803 100644 --- a/anton/publisher.py +++ b/anton/publisher.py @@ -7,6 +7,7 @@ import json import os import re +import sys import zipfile from pathlib import Path @@ -203,6 +204,7 @@ def publish( payload_dict["artifact_type"] = artifact.type payload_dict["artifact_id"] = artifact.id payload_dict["secrets"] = secrets + payload_dict["python_version"] = f"{sys.version_info.major}.{sys.version_info.minor}" if missing: payload_dict["missing_datasources"] = missing else: From 864b534bdc5848d6db8b5226b11709c63177a5a6 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Wed, 27 May 2026 17:07:13 +0300 Subject: [PATCH 24/37] del secret var len check --- anton/utils/datasources.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/anton/utils/datasources.py b/anton/utils/datasources.py index 2f2e4247..a4f15d2a 100644 --- a/anton/utils/datasources.py +++ b/anton/utils/datasources.py @@ -91,8 +91,6 @@ def scrub_credentials(text: str) -> str: """ for key in _DS_SECRET_VARS: value = os.environ.get(key, "") - if not value or len(value) < 4: - continue text = re.sub(r'(? Date: Fri, 29 May 2026 16:16:55 +0300 Subject: [PATCH 25/37] add secrets to bakcend.py --- anton/core/llm/prompts.py | 58 +++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index a6f4008f..5607e08c 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -519,6 +519,7 @@ ```python import argparse + import os from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -536,9 +537,23 @@ allow_headers=["*"], ) + # === Secrets === + # Keys are the canonical DS____ env-var names. Locally + # each value comes from os.environ (the data vault injected it into Anton's + # env, which `launch_backend` inherits). In the cloud, the shared runner + # overlays the decrypted values onto this dict before each request. Leave + # SECRETS empty if the backend uses none. READ a secret by key AT ITS POINT + # OF USE (inside the route) — never copy a SECRETS value into a module-level + # variable at import time. + SECRETS = {{ + # "DS_POSTGRES_PROD_DB__PASSWORD": os.environ.get("DS_POSTGRES_PROD_DB__PASSWORD"), + }} + # === API routes === @app.get("/api/hello") async def hello(): + # Example secret use (read at point of use, not at import): + # pw = SECRETS["DS_POSTGRES_PROD_DB__PASSWORD"] return {{"hello": "world"}} # Static mount MUST come AFTER all API routes (mount at "/" catches every @@ -548,8 +563,9 @@ async def hello(): if STATIC_DIR.exists(): app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static") - # AWS Lambda entry-point. lifespan="off" is REQUIRED — Lambda has no - # long-lived process to run FastAPI's startup/shutdown events on. + # CLOUD entry-point. lifespan="off" is REQUIRED — there is no + # long-lived process for FastAPI startup/shutdown. + # (Locally, `uvicorn.run(app, ...)` below serves the app directly.) handler = Mangum(app, lifespan="off") if __name__ == "__main__": @@ -561,11 +577,21 @@ async def hello(): ``` RULES (critical): - - Save the file as `/backend.py` — the filename and the \ -`handler` attribute name are load-bearing (Lambda config points to \ -`backend.handler`). Do NOT rename either. + - Save the file as `/backend.py` — the filename, the \ +`handler` attribute, and the `SECRETS` dict are load-bearing (the cloud \ +runner overlays secrets onto `backend.SECRETS` and invokes `backend.handler`). \ +Do NOT rename any of them. - Keep `Mangum(app, lifespan="off")`. Without `lifespan="off"` Mangum \ warns and may fail cold start. + - SECRETS: expose `SECRETS` as a module-level dict, keyed by the canonical \ +`DS____` name, with each entry initialized from \ +`os.environ.get(...)` (the local default). The cloud runner overlays the \ +decrypted values onto this same dict before each request. Read a secret AT \ +ITS POINT OF USE — `SECRETS["DS_..."]` inside the route — and NEVER hoist it \ +into a module-level variable at import time: the import runs before the \ +overlay, so the cloud value would be missed. If a credential-backed resource \ +(DB pool, API client) is needed, build it LAZILY on first request, never at \ +module level. - ALL API endpoints MUST live under the `/api/*` path prefix (e.g. \ `/api/items`, `/api/users/{{user_id}}`, `/api/search`). This is a hard \ contract between backend and frontend: it separates API traffic from the \ @@ -675,17 +701,19 @@ async def hello(): DEPLOYMENT NOTES: - Same `backend.py` runs in two modes: - LOCAL: `python backend.py --port=NNN` (used by `launch_backend`). \ -uvicorn serves the FastAPI app and the `static/` mount, frontend reachable at `/`. - - AWS LAMBDA: Lambda invokes `backend.handler` (Mangum-wrapped ASGI app) \ -per request. Statics are served by an external service (CloudFront/S3), so \ -the `StaticFiles` mount sits unused in Lambda — Lambda only sees `/api/*` \ -traffic via API Gateway / Function URL routing. +uvicorn serves the FastAPI app and the `static/` mount, frontend reachable at `/`. \ +Secrets come from the `DS_*` env vars in `SECRETS`' defaults. + - CLOUD: a shared runner overlays the decrypted secrets onto `backend.SECRETS` \ +and invokes `backend.handler` (the Mangum ASGI app) per request. Statics are \ +served separately (the gateway reads `static/` from object storage), so the \ +`StaticFiles` mount sits unused there — the runner only sees `/api/*` traffic. +- Secrets ride in the backend module's `SECRETS` dict, not `os.environ` — the \ +shared cloud runner injects them per request without polluting the process env. - The local backend process shuts down when the Anton CLI session ends (per MVP constraints). -- For production deployment to Lambda, the user packages `backend.py` + \ -`requirements.txt` (and any data files) as a zip or container image, \ -configures `Handler = backend.handler`, and exposes it via API Gateway or \ -Function URL. `DS_*` env vars from `datasources` in `metadata.json` must be \ -set on the Lambda function. +- `DS_*` credentials are resolved from the `datasources` declared in \ +`metadata.json`: locally the data vault injects them into the env; on publish \ +they are envelope-encrypted (Mind-Castle) and decrypted by the runner. Declare \ +every datasource the backend reads, or its secret will be missing in the cloud. PUBLISH OR SHARE: - Publishing is disabled for this MVP (per constraints), but preview is fully supported From 00d92c5635d4d74938dce13548122ebd469c1315 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Mon, 1 Jun 2026 13:52:32 +0300 Subject: [PATCH 26/37] fix secrets scrab --- anton/utils/datasources.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/anton/utils/datasources.py b/anton/utils/datasources.py index a4f15d2a..01e6364e 100644 --- a/anton/utils/datasources.py +++ b/anton/utils/datasources.py @@ -91,6 +91,8 @@ def scrub_credentials(text: str) -> str: """ for key in _DS_SECRET_VARS: value = os.environ.get(key, "") + if not value: + continue text = re.sub(r'(? Date: Mon, 1 Jun 2026 14:12:40 +0300 Subject: [PATCH 27/37] do not publish nested html --- anton/chat.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/anton/chat.py b/anton/chat.py index a42656ee..5ce72fd7 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -490,6 +490,20 @@ def _make_candidate(path: Path) -> tuple[str, Path, str, str] | None: return (artifact.name or slug, path, "fullstack", f"{slug}/") return None if path.is_file() and path.suffix.lower() in {".html", ".htm"}: + # If the file lives inside a fullstack artifact (e.g. + # `my-app/static/index.html`), publish the whole artifact folder + # rather than the orphaned frontend — the `.py`-based heuristic in + # `_is_publishable_html` can't see `backend.py` one level up. + try: + rel = path.relative_to(artifacts_root) + owner_slug = rel.parts[0] if len(rel.parts) > 1 else None + except ValueError: + owner_slug = None + if owner_slug: + owner = store.open(owner_slug) + if owner and owner.type in FULLSTACK_ARTIFACT_TYPES: + folder = artifacts_root / owner_slug + return (owner.name or owner_slug, folder, "fullstack", f"{owner_slug}/") if not _is_publishable_html(path, artifacts_root): return None title = _extract_html_title(path, re) From 1bfcce6cb610dea185fe5e664d90d07098471baf Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Mon, 1 Jun 2026 14:13:15 +0300 Subject: [PATCH 28/37] add schemaVersion to metadata.json --- .gitignore | 1 - anton/core/artifacts/models.py | 20 +++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 8ea0ad20..761b3c72 100644 --- a/.gitignore +++ b/.gitignore @@ -214,4 +214,3 @@ __marimo__/ # Anton .anton/ .DS_Store -artifacts/ diff --git a/anton/core/artifacts/models.py b/anton/core/artifacts/models.py index 1dd2d791..c1286234 100644 --- a/anton/core/artifacts/models.py +++ b/anton/core/artifacts/models.py @@ -2,13 +2,18 @@ Schema split: Server-managed (deterministic): - id, slug, createdAt, updatedAt, files[], provenance[] - Agent-supplied (validated at create_artifact time): - name, description, type + schemaVersion, id, slug, createdAt, updatedAt, files[], provenance[] + Agent-supplied (validated at create_artifact / update_artifact time): + name, description, type, primary, port, datasources[] The `Artifact` model is the on-disk source of truth — the README that sits alongside it is rendered FROM the metadata, not the other way around. + +`schemaVersion` tags the on-disk layout so future format changes can +be migrated deterministically. Bump `METADATA_SCHEMA_VERSION` whenever +the shape changes incompatibly; records written before this field +existed load as version 1 (the field default). """ from __future__ import annotations @@ -18,6 +23,11 @@ from pydantic import BaseModel, Field +# On-disk metadata.json layout version. Bump on incompatible changes +# and add a migration keyed off the loaded `schemaVersion`. +METADATA_SCHEMA_VERSION = 1 + + # Closed enum of artifact shapes. The renderer uses this to pick # the right preview affordance (iframe sandbox for html-app / # fullstack-stateless-app, "open" for documents, table preview for @@ -107,6 +117,10 @@ class Artifact(BaseModel): """ # ── Server-managed identity / timestamps ───────────────────── + # On-disk layout version. Records predating this field load as 1 + # (the default); `create()` stamps the current + # `METADATA_SCHEMA_VERSION` on fresh artifacts. + schemaVersion: int = 1 id: str # short hex (uuid4().hex[:8]) — stable across folder renames slug: str # matches folder name; sanitized from `name` with collision suffix createdAt: str From ccb15e5604cb50e98de028824b96380cc46919bc Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Mon, 1 Jun 2026 14:45:49 +0300 Subject: [PATCH 29/37] schemaVersion field --- anton/core/artifacts/store.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/anton/core/artifacts/store.py b/anton/core/artifacts/store.py index f9f7ea2f..dc998623 100644 --- a/anton/core/artifacts/store.py +++ b/anton/core/artifacts/store.py @@ -23,6 +23,7 @@ from pathlib import Path from anton.core.artifacts.models import ( + METADATA_SCHEMA_VERSION, Artifact, ArtifactType, DatasourceRef, @@ -160,6 +161,7 @@ def create( slug = self._unique_slug(slug_base) now = _utc_now() artifact = Artifact( + schemaVersion=METADATA_SCHEMA_VERSION, id=_new_id(), slug=slug, createdAt=now, From 245ec387d18a13bd596c80d99eef298e4f31a5e2 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Mon, 1 Jun 2026 15:40:06 +0300 Subject: [PATCH 30/37] make slug and env_prefix properties --- anton/core/artifacts/models.py | 23 +++++++++++++++++------ anton/core/tools/tool_handlers.py | 14 ++++++-------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/anton/core/artifacts/models.py b/anton/core/artifacts/models.py index c1286234..9a38884b 100644 --- a/anton/core/artifacts/models.py +++ b/anton/core/artifacts/models.py @@ -84,16 +84,27 @@ class DatasourceRef(BaseModel): Declared by the agent at backend-build time so the metadata can record which vault connections a fullstack artifact depends on. - Values are derived from the connection slug — `engine` and `name` - match a `~/.anton/data_vault/-` record; `env_prefix` - is the `DS__` token used to namespace the field-level - env vars handed to the backend subprocess. + `engine` and `name` match a `~/.anton/data_vault/-` + record and are the only stored fields. `slug` and `env_prefix` + are derived on access (not persisted): `slug` is `-`; + `env_prefix` is the `DS__` token used to namespace the + field-level env vars handed to the backend subprocess. """ - slug: str # e.g. "postgres-prod_db" engine: str # e.g. "postgres" name: str # e.g. "prod_db" - env_prefix: str # e.g. "DS_POSTGRES_PROD_DB" + + @property + def slug(self) -> str: + """`-` — the vault connection identifier.""" + return f"{self.engine}-{self.name}" + + @property + def env_prefix(self) -> str: + """`DS__` env-var namespace (special chars sanitized).""" + from anton.core.datasources.data_vault import _slug_env_prefix + + return _slug_env_prefix(self.engine, self.name) class ProvenanceEntry(BaseModel): diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index 743d080e..3c7be73e 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -144,7 +144,7 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict if "datasources" in tc_input: from anton.core.artifacts.models import DatasourceRef - from anton.core.datasources.data_vault import LocalDataVault, _slug_env_prefix + from anton.core.datasources.data_vault import LocalDataVault raw_list = tc_input.get("datasources") or [] if not isinstance(raw_list, list): @@ -166,12 +166,7 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict unknown.append(ref_slug) continue engine, name = known[ref_slug] - refs.append(DatasourceRef( - slug=ref_slug, - engine=engine, - name=name, - env_prefix=_slug_env_prefix(engine, name), - )) + refs.append(DatasourceRef(engine=engine, name=name)) if unknown: return ( f"Error: unknown datasource slug(s): {', '.join(unknown)}. " @@ -187,7 +182,10 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict "slug": artifact.slug, "primary": artifact.primary, "port": artifact.port, - "datasources": [d.model_dump() for d in artifact.datasources], + "datasources": [ + {"slug": d.slug, "engine": d.engine, "name": d.name, "env_prefix": d.env_prefix} + for d in artifact.datasources + ], }, indent=2) From f4d41a945d4bb80486ec765cefe05700a03471de Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Mon, 1 Jun 2026 15:42:56 +0300 Subject: [PATCH 31/37] fix update_artifact tool --- anton/core/tools/tool_handlers.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index 3c7be73e..ccf50d85 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -182,10 +182,7 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict "slug": artifact.slug, "primary": artifact.primary, "port": artifact.port, - "datasources": [ - {"slug": d.slug, "engine": d.engine, "name": d.name, "env_prefix": d.env_prefix} - for d in artifact.datasources - ], + "datasources": [d.slug for d in artifact.datasources], }, indent=2) From 83a464781098e04a4326a28ad9b91da097df9290 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Mon, 1 Jun 2026 16:02:15 +0300 Subject: [PATCH 32/37] fixed ambiguity between stateful and stateless apps --- anton/core/llm/prompts.py | 16 ++++++++++++---- anton/core/tools/tool_defs.py | 10 ++++++---- anton/core/tools/tool_handlers.py | 2 +- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 27914ada..2c7a16b4 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -231,10 +231,14 @@ - Data files the user will download or feed elsewhere (CSV, JSON, parquet) → \ `type="dataset"`, `primary="data.csv"`. - Generated images (PNG, SVG, etc.) → `type="image"`, `primary="chart.png"`. -- Self-contained HTML + JS + CSS apps that run with no backend → \ +- Fullstack web app (backend + frontend) that keeps NO local state between \ +requests — every request is self-contained and any persistence goes to external \ +data sources (see BACKEND & FULLSTACK section) → \ `type="fullstack-stateless-app"`, `primary="static/index.html"`. The frontend \ -lives in a `static/` subfolder of the artifact. -- Web apps that need a backend process (see BACKEND & FULLSTACK section) → \ +lives in a `static/` subfolder of the artifact, served by `backend.py`. +- Fullstack web app (backend + frontend) that DOES keep local state between \ +requests — e.g. a SQLite DB or other on-disk store the backend reads and writes \ +across requests (see BACKEND & FULLSTACK section) → \ `type="fullstack-stateful-app"`, `primary="static/index.html"`. The frontend \ lives in a `static/` subfolder of the artifact, served by `backend.py`. @@ -480,7 +484,11 @@ 1. REGISTER THE ARTIFACT: Follow the universal artifact contract from the \ ARTIFACTS section. For backend apps specifically: - - `type`: `"fullstack-stateful-app"` (every app built here needs a backend process). + - `type`: `"fullstack-stateless-app"` — apps built here keep NO local state \ +between requests (the deployment target is stateless: AWS Lambda with a \ +read-only filesystem, see RULES and DEPLOYMENT NOTES below). All persistence \ +goes through external data sources. (`fullstack-stateful-app` is reserved for \ +a future local-persistence deployment and is not built by this workflow yet.) - `primary`: set to `"static/index.html"` — the frontend ALWAYS lives in a \ `static/` subfolder of the artifact (see steps 4 and 5 below). Use the returned `` for ALL subsequent writes — `backend.py` \ diff --git a/anton/core/tools/tool_defs.py b/anton/core/tools/tool_defs.py index dc9a4a82..55a8ddb1 100644 --- a/anton/core/tools/tool_defs.py +++ b/anton/core/tools/tool_defs.py @@ -162,8 +162,10 @@ class ToolDef: "- dataset: data files (CSV, JSON, parquet) the user downloads or feeds elsewhere\n" "- image: a generated image (PNG, SVG, etc.)\n" "- mixed: multi-modal output that doesn't fit the above\n" - "- fullstack-stateless-app: HTML + JS + CSS that runs without a server\n" - "- fullstack-stateful-app: needs a backend process to serve\n\n" + "- fullstack-stateless-app: fullstack web app (backend + frontend) that keeps " + "no local state between requests; all persistence goes to external data sources\n" + "- fullstack-stateful-app: fullstack web app (backend + frontend) that keeps " + "local state between requests (e.g. an on-disk SQLite DB)\n\n" "Pass `primary` (optional) when you already know the entry-point " "filename you'll write — e.g. `\"dashboard.html\"` for an html-app, " "`\"index.html\"` for a fullstack app, `\"report.pdf\"` for a " @@ -215,11 +217,11 @@ class ToolDef: "- `primary`: relative path of the entry-point file (e.g. \"index.html\"). " "Pass empty string to clear (renderer reverts to heuristic: " "`index.html` → newest `.html` → newest non-housekeeping file).\n" - "- `port`: port the backend process is listening on (fullstack-stateful-app only). " + "- `port`: port the backend process is listening on (fullstack apps only). " "Set this after the server confirms it is up.\n" "- `datasources`: list of vault-connection slugs the artifact's backend " "reads from (e.g. `[\"postgres-prod_db\", \"hubspot-main\"]`). REQUIRED " - "for `fullstack-stateful-app` whose `backend.py` references any `DS_*` " + "for a `fullstack-stateless-app` whose `backend.py` references any `DS_*` " "env var — declare it right after writing `backend.py` so metadata.json " "captures which connections the deployable depends on. Slugs must match " "existing vault connections (see `Connected Data Sources` in the system " diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index ccf50d85..f3f729cb 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -122,7 +122,7 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict Only fields present in the input are modified. Supports: - `primary`: entry-point file path (empty string to clear) - - `port`: backend port number (fullstack-stateful-app only) + - `port`: backend port number (fullstack apps only) - `datasources`: list of vault-connection slugs the backend reads from. `engine`, `name`, and `env_prefix` are derived from the vault. """ From cb865c37e5a5726947624ad28efee49aefdc1f8a Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Mon, 1 Jun 2026 16:16:45 +0300 Subject: [PATCH 33/37] prompt changes --- anton/core/llm/prompts.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 2c7a16b4..2ee58b47 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -623,8 +623,13 @@ async def hello(): (`USERS = {{}}`, `SESSIONS = []`). In Lambda these globals may or may not \ survive between invocations — never rely on them. All persistence goes \ through external data sources. - - FILESYSTEM: assume read-only. The only writable path in Lambda is \ -`/tmp` (ephemeral, 512 MB). Do NOT write to `` at runtime. + - FILESYSTEM: do NOT persist files at runtime. Treat the filesystem as \ +read-only and non-persistent — anything written is lost between requests and \ +may fail outright depending on the host (Linux, Windows, or a read-only cloud \ +sandbox). NEVER write to `` at runtime, and never rely on a \ +file surviving to a later request. All persistence goes through external data \ +sources. If a request genuinely needs scratch space, use the OS temp dir via \ +`tempfile` and treat it as ephemeral (gone the moment the request ends). - LOGGING: `print()` and `logging.getLogger(__name__).info(...)` both go \ to CloudWatch in Lambda and to `backend.log` locally — no extra setup needed. - REQUIREMENTS: always save a `/requirements.txt` with at \ @@ -652,7 +657,10 @@ async def hello(): 5. BUILD FRONTEND (if needed): In a separate scratchpad: - Build a single-file HTML dashboard or web interface - Include all CSS and JS inlined (no external file references) - - Follow the VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT guidelines + - Apply the HTML build guidance from the `VISUALIZATIONS` section above \ +(single self-contained HTML file; Apache ECharts via CDN for charts; dark \ +theme #0d1117; responsive layout with a viewport meta tag). If that section \ +is not present in this prompt, follow these same defaults regardless. - Save the entry-point to `/static/index.html` (create the \ `static/` subfolder if needed). ANY additional frontend assets (separate CSS, \ JS, images, fonts, large data .js payloads) MUST also live under \ @@ -722,13 +730,8 @@ async def hello(): - Secrets ride in the backend module's `SECRETS` dict, not `os.environ` — the \ shared cloud runner injects them per request without polluting the process env. - The local backend process shuts down when the Anton CLI session ends (per MVP constraints). -- `DS_*` credentials are resolved from the `datasources` declared in \ -`metadata.json`: locally the data vault injects them into the env; on publish \ -they are envelope-encrypted (Mind-Castle) and decrypted by the runner. Declare \ -every datasource the backend reads, or its secret will be missing in the cloud. PUBLISH OR SHARE: -- Publishing is disabled for this MVP (per constraints), but preview is fully supported - After building, offer to preview the frontend by directing the user to the \ URL returned by `launch_backend` - The backend must be running for the frontend to work From fac6db44f0171bef0acfe83a023324092f372bff Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Mon, 1 Jun 2026 16:56:19 +0300 Subject: [PATCH 34/37] fixes --- anton/core/artifacts/backend_launcher.py | 13 +++++++++---- anton/core/tools/tool_handlers.py | 5 ++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/anton/core/artifacts/backend_launcher.py b/anton/core/artifacts/backend_launcher.py index d3c456e3..9499a7a2 100644 --- a/anton/core/artifacts/backend_launcher.py +++ b/anton/core/artifacts/backend_launcher.py @@ -181,7 +181,7 @@ def _set_pdeathsig() -> None: # Readiness — try HTTP first, fall back to TCP-connect. HTTP 4xx # still counts as "process is alive and answering" → ready. - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() deadline = loop.time() + health_timeout ready = False last_err: str | None = None @@ -212,9 +212,14 @@ def _set_pdeathsig() -> None: except Exception as exc: last_err = str(exc) try: - with socket.create_connection(("127.0.0.1", port), timeout=0.5): - ready = True - break + await loop.run_in_executor( + None, + lambda: socket.create_connection( + ("127.0.0.1", port), timeout=0.5 + ).close(), + ) + ready = True + break except OSError: await asyncio.sleep(0.2) diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index f3f729cb..c23ca94e 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -140,7 +140,10 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict if "primary" in tc_input: kwargs["primary"] = tc_input["primary"] if "port" in tc_input: - kwargs["port"] = tc_input["port"] + try: + kwargs["port"] = int(tc_input["port"]) if tc_input["port"] is not None else None + except (TypeError, ValueError): + return "Error: `port` must be a number." if "datasources" in tc_input: from anton.core.artifacts.models import DatasourceRef From 826b797ba6beabfda4743fc35771630532a6408a Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Tue, 2 Jun 2026 18:26:47 +0300 Subject: [PATCH 35/37] inject env vars when start backend --- anton/core/artifacts/backend_launcher.py | 7 ++++- anton/core/datasources/data_vault.py | 40 ++++++++++++++++-------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/anton/core/artifacts/backend_launcher.py b/anton/core/artifacts/backend_launcher.py index 9499a7a2..03735a4a 100644 --- a/anton/core/artifacts/backend_launcher.py +++ b/anton/core/artifacts/backend_launcher.py @@ -49,6 +49,7 @@ async def launch_artifact_backend( tracked_backends: dict[str, dict], path: str = "backend.py", extra_args: list[str] | None = None, + extra_env: dict[str, str] | None = None, health_path: str = "/", health_timeout: float = 10.0, ) -> dict | str: @@ -64,6 +65,10 @@ async def launch_artifact_backend( spawned `asyncio.subprocess.Process` under `slug` and reaps any previously-tracked process for the same slug before spawning. The caller is responsible for cleaning the dict on shutdown. + + `extra_env` is merged over the inherited `os.environ` for the spawned + process only (e.g. datasource `DS_*` secrets) — it never mutates the + parent's environment, keeping secrets scoped to the backend subprocess. """ extra_args = list(extra_args or []) folder = artifact_folder @@ -168,7 +173,7 @@ def _set_pdeathsig() -> None: stderr=log_fd, stdin=asyncio.subprocess.DEVNULL, preexec_fn=preexec_fn, - env={**os.environ}, + env={**os.environ, **(extra_env or {})}, ) except OSError as exc: log_fd.close() diff --git a/anton/core/datasources/data_vault.py b/anton/core/datasources/data_vault.py index aca6a49d..c2358b21 100644 --- a/anton/core/datasources/data_vault.py +++ b/anton/core/datasources/data_vault.py @@ -305,31 +305,45 @@ def list_connections(self) -> list[dict[str, str]]: continue return results - def inject_env(self, engine: str, name: str, *, flat: bool = False) -> list[str] | None: - """Load credentials and set DS_* environment variables. + def env_for(self, engine: str, name: str, *, flat: bool = False) -> dict[str, str] | None: + """Build the DS_* env mapping for a connection WITHOUT mutating os.environ. - Default (flat=False): injects namespaced vars, e.g. DS_POSTGRES_PROD_DB__HOST. - flat=True: injects legacy flat vars, e.g. DS_HOST — use only during + Default (flat=False): namespaced vars, e.g. DS_POSTGRES_PROD_DB__HOST. + flat=True: legacy flat vars, e.g. DS_HOST — use only during single-connection test_snippet execution. - Returns the list of env var names set, or None if connection not found. + Returns the {var: value} mapping, or None if connection not found. + Use this when the env should reach only a specific subprocess (pass + the result as an explicit `env`); use `inject_env` when the variables + must be visible in the current process. """ fields = self.load(engine, name) if fields is None: return None - var_names: list[str] = [] + env: dict[str, str] = {} if flat: for key, value in fields.items(): - var = f"DS_{key.upper()}" - os.environ[var] = value - var_names.append(var) + env[f"DS_{key.upper()}"] = value else: prefix = _slug_env_prefix(engine, name) for key, value in fields.items(): - var = f"{prefix}__{key.upper()}" - os.environ[var] = value if isinstance(value, str) else str(value) - var_names.append(var) - return var_names + env[f"{prefix}__{key.upper()}"] = value if isinstance(value, str) else str(value) + return env + + def inject_env(self, engine: str, name: str, *, flat: bool = False) -> list[str] | None: + """Load credentials and set DS_* environment variables. + + Default (flat=False): injects namespaced vars, e.g. DS_POSTGRES_PROD_DB__HOST. + flat=True: injects legacy flat vars, e.g. DS_HOST — use only during + single-connection test_snippet execution. + + Returns the list of env var names set, or None if connection not found. + """ + env = self.env_for(engine, name, flat=flat) + if env is None: + return None + os.environ.update(env) + return list(env) def clear_ds_env(self) -> None: """Remove all DS_* variables from os.environ.""" From 81e78ccf6099f58bb48d9ec5fc300eb7e36dc5b5 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Thu, 11 Jun 2026 17:38:25 +0300 Subject: [PATCH 36/37] update prompt --- anton/core/llm/prompts.py | 75 +++++++++++++++++++++++------------ anton/core/tools/tool_defs.py | 18 ++++++--- 2 files changed, 62 insertions(+), 31 deletions(-) diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 2ee58b47..245a48c1 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -134,7 +134,7 @@ call is synchronous. - All .anton/.env variables are available as environment variables (os.environ). - Connected data source credentials are injected as namespaced environment \ -variables in the form DS___ \ +variables in the form DS____ \ (e.g. DS_POSTGRES_PROD_DB__HOST, DS_POSTGRES_PROD_DB__PASSWORD, \ DS_HUBSPOT_MAIN__ACCESS_TOKEN). Use those variables directly in scratchpad \ code and never read ~/.anton/data_vault/ files directly. @@ -231,16 +231,18 @@ - Data files the user will download or feed elsewhere (CSV, JSON, parquet) → \ `type="dataset"`, `primary="data.csv"`. - Generated images (PNG, SVG, etc.) → `type="image"`, `primary="chart.png"`. -- Fullstack web app (backend + frontend) that keeps NO local state between \ -requests — every request is self-contained and any persistence goes to external \ -data sources (see BACKEND & FULLSTACK section) → \ +- Fullstack web app (backend + frontend) — the DEFAULT fullstack type: keeps \ +NO local state between requests; every request is self-contained and any \ +persistence goes to external data sources (see BACKEND & FULLSTACK section) → \ `type="fullstack-stateless-app"`, `primary="static/index.html"`. The frontend \ lives in a `static/` subfolder of the artifact, served by `backend.py`. -- Fullstack web app (backend + frontend) that DOES keep local state between \ -requests — e.g. a SQLite DB or other on-disk store the backend reads and writes \ -across requests (see BACKEND & FULLSTACK section) → \ -`type="fullstack-stateful-app"`, `primary="static/index.html"`. The frontend \ -lives in a `static/` subfolder of the artifact, served by `backend.py`. +- Fullstack web app (backend + frontend) that keeps local state between \ +requests — e.g. a SQLite DB or other on-disk store the backend reads and \ +writes across requests. Use ONLY when that state genuinely cannot live in an \ +external data source; prefer stateless when in doubt (see BACKEND & FULLSTACK \ +section) → `type="fullstack-stateful-app"`, `primary="static/index.html"`. \ +The frontend lives in a `static/` subfolder of the artifact, served by \ +`backend.py`. WHEN NOT TO REGISTER: - Pure chat answers, tables, or markdown rendered inline in the conversation \ @@ -479,16 +481,35 @@ BACKEND_GENERATION_PROMPT = """\ BACKEND & FULLSTACK APPLICATION GENERATION: -When the user asks to build a backend service, web application with a backend, or stateless \ -API-driven system, follow this workflow: +When the user asks to build a backend service, web application with a backend, or \ +API-driven system, follow this workflow. It covers BOTH fullstack artifact types — \ +the steps are identical; only the LOCAL STATE rule (see RULES) differs. + +HARD CONTRACT (violating ANY of these breaks launch or deployment — full \ +explanations in the RULES of step 4): +- The backend file is `/backend.py`; the `handler` attribute \ +and the `SECRETS` dict keep exactly those names. +- `handler = Mangum(app, lifespan="off")`. +- ALL API routes live under `/api/*` and are registered BEFORE \ +`app.mount("/", StaticFiles(...))`. +- The script accepts `--port` via argparse and binds to it — never hardcode a port. +- The entire frontend lives in `/static/`, entry-point \ +`static/index.html`. +- `/requirements.txt` exists and lists at least `fastapi`, \ +`mangum`, `uvicorn`. +- Secrets are read from `SECRETS[...]` at their point of use inside routes — \ +never copied into module-level variables at import time. 1. REGISTER THE ARTIFACT: Follow the universal artifact contract from the \ ARTIFACTS section. For backend apps specifically: - - `type`: `"fullstack-stateless-app"` — apps built here keep NO local state \ -between requests (the deployment target is stateless: AWS Lambda with a \ -read-only filesystem, see RULES and DEPLOYMENT NOTES below). All persistence \ -goes through external data sources. (`fullstack-stateful-app` is reserved for \ -a future local-persistence deployment and is not built by this workflow yet.) + - `type`: pick between the two fullstack types: + * `"fullstack-stateless-app"` — the DEFAULT. Always start here. The app \ +keeps NO local state between requests (the deployment target is stateless: \ +AWS Lambda with a read-only filesystem, see RULES and DEPLOYMENT NOTES below); \ +all persistence goes through external data sources. + * `"fullstack-stateful-app"` — ONLY when the app genuinely requires local \ +on-disk state between requests (e.g. a SQLite DB) AND that state cannot live \ +in an external connected data source. When in doubt, choose stateless. - `primary`: set to `"static/index.html"` — the frontend ALWAYS lives in a \ `static/` subfolder of the artifact (see steps 4 and 5 below). Use the returned `` for ALL subsequent writes — `backend.py` \ @@ -619,17 +640,21 @@ async def hello(): - Prefer `async def` for I/O-bound routes (DB queries, external HTTP \ calls via `httpx.AsyncClient`). Sync `def` is fine for trivial CPU work, but \ sync blocking I/O inside an async app stalls the event loop. - - STATELESS: no module-level mutable caches that matter across requests \ -(`USERS = {{}}`, `SESSIONS = []`). In Lambda these globals may or may not \ -survive between invocations — never rely on them. All persistence goes \ -through external data sources. - - FILESYSTEM: do NOT persist files at runtime. Treat the filesystem as \ -read-only and non-persistent — anything written is lost between requests and \ + - LOCAL STATE (the ONE rule that differs between the two fullstack types): + * `fullstack-stateless-app`: no local state of any kind survives a \ +request. No module-level mutable caches that matter across requests \ +(`USERS = {{}}`, `SESSIONS = []`) — in Lambda these globals may or may not \ +survive between invocations, never rely on them. Treat the filesystem as \ +read-only and non-persistent: anything written is lost between requests and \ may fail outright depending on the host (Linux, Windows, or a read-only cloud \ sandbox). NEVER write to `` at runtime, and never rely on a \ -file surviving to a later request. All persistence goes through external data \ -sources. If a request genuinely needs scratch space, use the OS temp dir via \ -`tempfile` and treat it as ephemeral (gone the moment the request ends). +file surviving to a later request. If a request genuinely needs scratch \ +space, use the OS temp dir via `tempfile` and treat it as ephemeral (gone \ +the moment the request ends). ALL persistence goes through external data \ +sources. + * `fullstack-stateful-app`: local on-disk state (e.g. a SQLite file) IS \ +allowed — keep it in the artifact root (`/`, next to \ +`backend.py`). Every other rule in this list still applies. - LOGGING: `print()` and `logging.getLogger(__name__).info(...)` both go \ to CloudWatch in Lambda and to `backend.log` locally — no extra setup needed. - REQUIREMENTS: always save a `/requirements.txt` with at \ diff --git a/anton/core/tools/tool_defs.py b/anton/core/tools/tool_defs.py index 55a8ddb1..2b0b182c 100644 --- a/anton/core/tools/tool_defs.py +++ b/anton/core/tools/tool_defs.py @@ -163,12 +163,14 @@ class ToolDef: "- image: a generated image (PNG, SVG, etc.)\n" "- mixed: multi-modal output that doesn't fit the above\n" "- fullstack-stateless-app: fullstack web app (backend + frontend) that keeps " - "no local state between requests; all persistence goes to external data sources\n" + "no local state between requests; all persistence goes to external data sources. " + "DEFAULT for fullstack apps\n" "- fullstack-stateful-app: fullstack web app (backend + frontend) that keeps " - "local state between requests (e.g. an on-disk SQLite DB)\n\n" + "local state between requests (e.g. an on-disk SQLite DB). Use ONLY when that " + "state truly cannot live in an external data source; prefer stateless when in doubt\n\n" "Pass `primary` (optional) when you already know the entry-point " "filename you'll write — e.g. `\"dashboard.html\"` for an html-app, " - "`\"index.html\"` for a fullstack app, `\"report.pdf\"` for a " + "`\"static/index.html\"` for a fullstack app, `\"report.pdf\"` for a " "document. The renderer uses it to decide what to open by default. " "Skip when you don't know yet — the renderer falls back to a " "heuristic, and you can set it later via `update_artifact`.\n\n" @@ -218,11 +220,13 @@ class ToolDef: "Pass empty string to clear (renderer reverts to heuristic: " "`index.html` → newest `.html` → newest non-housekeeping file).\n" "- `port`: port the backend process is listening on (fullstack apps only). " - "Set this after the server confirms it is up.\n" + "Normally written automatically by `launch_backend` — set manually only " + "if you started the server some other way.\n" "- `datasources`: list of vault-connection slugs the artifact's backend " "reads from (e.g. `[\"postgres-prod_db\", \"hubspot-main\"]`). REQUIRED " - "for a `fullstack-stateless-app` whose `backend.py` references any `DS_*` " - "env var — declare it right after writing `backend.py` so metadata.json " + "for fullstack apps whose `backend.py` references any " + "`DS____` env var — declare it right after writing " + "`backend.py` so metadata.json " "captures which connections the deployable depends on. Slugs must match " "existing vault connections (see `Connected Data Sources` in the system " "prompt). Pass `[]` to clear." @@ -310,6 +314,8 @@ class ToolDef: "(plus any `extra_args`), waits until the server is reachable, " "records the port in the artifact's `metadata.json`, and returns " "`{slug, port, pid, url, log_path}` as JSON.\n\n" + "The spawned process inherits Anton's environment, including the " + "`DS____` variables of connected data sources.\n\n" "Runs in a scratchpad named exactly `` (created on first call). " "If `/requirements.txt` exists, its package lines are " "installed into that scratchpad's venv before spawn — install output " From e46fbadb146f5a754455afaa9b70669424dfea05 Mon Sep 17 00:00:00 2001 From: Max Stepanov Date: Thu, 11 Jun 2026 18:33:14 +0300 Subject: [PATCH 37/37] del output_context --- anton/core/runtime.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/anton/core/runtime.py b/anton/core/runtime.py index f840d860..5f10f510 100644 --- a/anton/core/runtime.py +++ b/anton/core/runtime.py @@ -62,7 +62,6 @@ async def build_chat_session( model: Optional[str] = None, extra_tools: Optional[Sequence[Any]] = None, system_prompt_suffix: Optional[str] = None, - output_context: Optional[str] = None, ): """Build a ChatSession scoped to one workspace. @@ -83,9 +82,6 @@ async def build_chat_session( system_prompt_suffix Free-form text appended to the system prompt. Hosts use this to nudge tone or describe their UI affordances. None → no suffix. - output_context - Override for the per-session output-folder hint. None → use the default template - pointing at `settings.artifacts_dir`. Returns ------- @@ -144,11 +140,6 @@ async def build_chat_session( history_store = HistoryStore(episodes_dir) initial_history = history_store.load(session_id) - resolved_output_context = output_context or ( - f"Save generated files and dashboards to `{output_dir}`. " - "When you create a user-facing HTML dashboard or report, save it there." - ) - data_vault = LocalDataVault() if LocalDataVault is not None else None google_drive_oauth_connected = False if data_vault is not None: @@ -186,8 +177,8 @@ async def build_chat_session( system_prompt_context=SystemPromptContext( runtime_context=build_runtime_context(settings), suffix=final_suffix, - output_context=resolved_output_context, ), + output_dir=str(output_dir), workspace=workspace, data_vault=data_vault, initial_history=initial_history,