Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -208,4 +208,5 @@ __marimo__/

# Anton
.anton/
.DS_Store
.DS_Store
artifacts/
57 changes: 40 additions & 17 deletions anton/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,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:
Expand Down Expand Up @@ -459,12 +475,19 @@ async def _handle_publish(
if not target.is_absolute():
target = Path(settings.workspace_path) / file_arg
else:
# Recursively list HTML files under any artifact, sorted by mtime.
html_files = sorted(
artifacts_root.rglob("*.html"), key=lambda f: f.stat().st_mtime, reverse=True
) if artifacts_root.is_dir() else []
# Recursively list publishable HTML files under any artifact, sorted by mtime.
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 = []

if not html_files:
console.print(f" [anton.warning]No HTML files found under {artifacts_root}/[/]")
console.print(f" [anton.warning]No publishable HTML files found under {artifacts_root}/[/]")
console.print()
return

Expand All @@ -478,9 +501,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(artifacts_root).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)[/]")
Expand Down Expand Up @@ -512,6 +536,14 @@ async def _handle_publish(
console.print()
return

# Check if file is publishable
if 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
published_json = publish_index_dir / ".published.json"
published_map = {}
Expand All @@ -522,7 +554,7 @@ async def _handle_publish(
pass

report_id = None
file_key = target.name
file_key = target.relative_to(artifacts_root).as_posix()
prev = published_map.get(file_key)

if prev and prev.get("report_id"):
Expand Down Expand Up @@ -1135,7 +1167,6 @@ async def _chat_loop(
# Build runtime context so the LLM knows what it's running on
runtime_context = build_runtime_context(settings)

artifacts_path = f"{settings.artifacts_dir.rstrip('/')}/"
from anton.chat_session import get_runtime_factory

session = ChatSession(ChatSessionConfig(
Expand All @@ -1146,21 +1177,13 @@ async def _chat_loop(
episodic=episodic,
system_prompt_context=SystemPromptContext(
runtime_context=runtime_context,
# See `chat_session.create_session` for the full version
# of this prompt fragment — both call sites use the same
# artifact-flow guidance.
output_context=(
f"User-facing artifacts live under `{artifacts_path}`. "
"Before producing one, call `create_artifact(name, description, type)`; "
"the tool returns the absolute folder path you should write into. "
"To modify an existing artifact, use `list_artifacts` then `open_artifact(slug)`."
),
),
workspace=workspace,
console=console,
history_store=history_store,
session_id=current_session_id,
proactive_dashboards=settings.proactive_dashboards,
output_dir=settings.artifacts_dir,
tools=[CONNECT_DATASOURCE_TOOL, PUBLISH_TOOL],
))

Expand Down
12 changes: 1 addition & 11 deletions anton/chat_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ def rebuild_session(
refresh_knowledge(settings, cortex)

runtime_context = build_runtime_context(settings)
artifacts_path = f"{settings.artifacts_dir.rstrip('/')}/"
return ChatSession(ChatSessionConfig(
llm_client=state["llm_client"],
runtime_factory=get_runtime_factory(settings),
Expand All @@ -110,20 +109,11 @@ def rebuild_session(
episodic=episodic,
system_prompt_context=SystemPromptContext(
runtime_context=runtime_context,
# Tell the agent where artifacts live + how to claim a folder.
# `create_artifact` returns the actual path to write into;
# `<artifacts_path>` here is just so the LLM has the
# workspace anchor in mind when picking names.
output_context=(
f"User-facing artifacts live under `{artifacts_path}`. "
"Before producing one, call `create_artifact(name, description, type)`; "
"the tool returns the absolute folder path you should write into. "
"To modify an existing artifact, use `list_artifacts` then `open_artifact(slug)`."
),
),
workspace=workspace,
console=console,
history_store=history_store,
session_id=session_id,
proactive_dashboards=settings.proactive_dashboards,
output_dir=settings.artifacts_dir,
))
1 change: 1 addition & 0 deletions anton/core/artifacts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 21 additions & 9 deletions anton/core/artifacts/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions anton/core/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions anton/core/backends/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,31 @@ 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 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
Expand Down
11 changes: 11 additions & 0 deletions anton/core/backends/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
17 changes: 9 additions & 8 deletions anton/core/llm/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,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:
Expand Down Expand Up @@ -110,15 +108,15 @@ def _build_visualizations_section(
self,
*,
proactive_dashboards: bool,
output_context: str,
output_dir: str,
) -> str:
visualizations_output_format_prompt = (
VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT
if proactive_dashboards
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)

Expand All @@ -128,6 +126,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 = "",
Expand All @@ -137,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 = ""
Expand All @@ -152,6 +151,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
Expand Down
Loading
Loading