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
61 changes: 11 additions & 50 deletions anton/core/llm/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,56 +223,17 @@

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 \
`<workspace>/artifacts/<slug>/`, 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"`.
- 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 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 \
(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 `<artifact_path>` 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.
4. AFTER FINISHING — reference the artifact in your final message. Once the \
artifact's files are written, tell the user what was created and point to it by \
`name` and `slug`, and include the primary file's path \
(`<artifact_path>/<primary>`) so it is clickable/openable in a plain CLI. NEVER \
end with only a description of the content and no pointer to the result. (For \
fullstack apps, prefer the `url` returned by `launch_backend` as the primary \
pointer — see the BACKEND & FULLSTACK section.)
Any file the user is meant to open, view, download, or run — an HTML \
dashboard/chart/report, a document (md/pdf/docx), a dataset (csv/json/parquet), \
a generated image, or a fullstack web app — is an ARTIFACT and MUST be \
registered before any file is written. The registration tools \
(`create_artifact`, `list_artifacts`, `open_artifact`, `update_artifact`, \
`launch_backend`) and the full contract are NOT in this prompt — they live in \
the `artifacts` skill. MANDATORY: call `recall_skill("artifacts")` BEFORE \
producing any user-facing file; recalling it unlocks those tools. Pure chat \
answers, inline tables/markdown, and internal scratchpad files are NOT \
artifacts — don't register those. If there is any chance the task produces a \
saved deliverable, recall the skill first — recalling it too often is fine.
"""


Expand Down
28 changes: 28 additions & 0 deletions anton/core/memory/builtin_skills/artifacts/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
name: artifacts
description: 'MANDATORY reading before producing ANY user-facing output that is saved to disk — an HTML dashboard/chart/report, a document (markdown/pdf/docx), a dataset (csv/json/parquet), a generated image, or a fullstack web app. Recall it BEFORE writing the first file. Loads the artifact tools (create_artifact, list_artifacts, open_artifact, update_artifact, launch_backend) and the full registration contract: when to register, when NOT to, the folder/path rules, and how to reference the result in your final message. When in doubt, recall it.'
metadata:
display_name: Artifacts (user-facing output files)
provenance: builtin
---
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 `<workspace>/artifacts/<slug>/`, 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"`.
- 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 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 (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 `<artifact_path>` 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.
4. AFTER FINISHING — reference the artifact in your final message. Once the artifact's files are written, tell the user what was created and point to it by `name` and `slug`, and include the primary file's path (`<artifact_path>/<primary>`) so it is clickable/openable in a plain CLI. NEVER end with only a description of the content and no pointer to the result. (For fullstack apps, prefer the `url` returned by `launch_backend` as the primary pointer — see the BACKEND & FULLSTACK section.)
69 changes: 53 additions & 16 deletions anton/core/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,12 @@
from anton.core.backends.manager import ScratchpadManager
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,
READ_IMAGE_TOOL,
RECALL_TOOL,
SCRATCHPAD_TOOL,
SELECT_PATH_TOOL,
UPDATE_ARTIFACT_METADATA_TOOL,
SKILL_TOOL_BUNDLES,
ToolDef,
)
from anton.core.interaction.selection import SelectionElicitor
Expand Down Expand Up @@ -997,6 +993,10 @@ def _build_tools(self) -> list[dict]:
self._build_core_tools()
for tool in self._extra_tools:
self.tool_registry.register_tool(tool)
# After extra tools: keeps on-demand bundles at the same tail
# position the live recall path uses, so tool order stays
# cache-stable across turns.
self._replay_tool_bundles_from_history()
return self.tool_registry.dump()

def _build_core_tools(self) -> None:
Expand Down Expand Up @@ -1050,17 +1050,43 @@ def _build_core_tools(self) -> None:
from anton.core.tools.web_tools import WEB_FETCH_FALLBACK_TOOL
self.tool_registry.register_tool(WEB_FETCH_FALLBACK_TOOL)

# Artifacts — only register when a workspace is bound to the
# session. Bare-cwd CLI sessions without `resolve_workspace`
# have nowhere to write artifacts to, and the tool handlers
# would just return error strings — better to hide the tools
# entirely so the LLM doesn't try to use them.
if self._workspace is not 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(UPDATE_ARTIFACT_METADATA_TOOL)
self.tool_registry.register_tool(LAUNCH_BACKEND_TOOL)
# Artifact tools are NOT registered here
# they're unlocked on demand via `_register_tool_bundle`

def _replay_tool_bundles_from_history(self) -> None:
"""Re-unlock bundles from prior `recall_skill` calls in the history.

Lets sticky tools survive a session rebuild (e.g. server restart). If
compaction evicted the call, the model re-recalls when next needed.
"""
for msg in self._history:
content = msg.get("content") if isinstance(msg, dict) else None
if not isinstance(content, list):
continue
for block in content:
if (
isinstance(block, dict)
and block.get("type") == "tool_use"
and block.get("name") == "recall_skill"
):
label = (block.get("input") or {}).get("label")
if isinstance(label, str):
self._register_tool_bundle(label.strip())

def _register_tool_bundle(self, label: str) -> None:
"""Register the tools a recalled skill unlocks (sticky for the session).

No-op if the label maps to no bundle. Artifact tools need a bound
workspace, else their handlers only error — keep them hidden.
register_tool dedups by name, so repeated recalls are safe.
"""
bundle = SKILL_TOOL_BUNDLES.get(label)
if not bundle:
return
if self._workspace is None:
return
for tool in bundle:
self.tool_registry.register_tool(tool)

async def close(self) -> None:
"""Clean up scratchpads and other resources."""
Expand Down Expand Up @@ -1768,6 +1794,9 @@ def _inject_recalled_skills(self, labels: list[str]) -> None:
skill = None
if skill is None:
continue
# Unlock gated tools (ENG-764) before the skip below, so a preload
# registers the bundle even when it won't re-inject the body.
self._register_tool_bundle(skill.label)
# Skip skills whose full body is already in context — mirrors
# handle_recall_skill's stub path so a preload can't duplicate a
# procedure the planning model already has (wasted tokens).
Expand Down Expand Up @@ -1929,6 +1958,10 @@ async def turn(self, user_input: str | list[dict]) -> str:

self._append_history({"role": "user", "content": tool_results})

# Rebuild: a tool this round (e.g. recall_skill) may have
# registered new tools the follow-up must see.
tools = self._build_tools()

# Get follow-up from LLM
response = await self.plan_with_recovery(system=system, tools=tools)

Expand Down Expand Up @@ -2636,6 +2669,10 @@ async def _stream_and_handle_tools(

self._append_history({"role": "user", "content": tool_results})

# Rebuild: a tool this round (e.g. recall_skill) may have
# registered new tools the follow-up must see.
tools = self._build_tools()

# Signal that tools are done and LLM is now reasoning
_reasoning_t0 = _time.monotonic()
yield StreamTaskProgress(
Expand Down
7 changes: 7 additions & 0 deletions anton/core/tools/recall_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,13 @@ async def handle_recall_skill(session: "ChatSession", tc_input: dict) -> str:
f"procedure below and proceed without a recalled skill."
)

# Unlock any tools this skill gates (ENG-764), before the early-return
# paths below so the bundle is registered regardless of how we return.
# Duck-typed session: guard for minimal sessions lacking the hook.
register_bundle = getattr(session, "_register_tool_bundle", None)
if callable(register_bundle):
register_bundle(skill.label)

if _already_in_history(session, skill.label):
# NOTE: this stub must never contain _recall_marker() or the
# procedure header — otherwise a stub surviving compaction would
Expand Down
26 changes: 26 additions & 0 deletions anton/core/tools/tool_defs.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,3 +502,29 @@ class ToolDef:
},
handler=handle_select_path,
)


# ---------------------------------------------------------------------------
# Skill → tool-bundle activation (ENG-764)
# ---------------------------------------------------------------------------
# These tools are NOT registered at session start. They enter the registry
# only when the model recalls one of the mapped skills, keeping ~1.3k tokens
# of schemas out of the `tools` array for sessions that never produce
# artifacts. Registration is sticky for the session (see ChatSession).

ARTIFACT_TOOL_BUNDLE: list[ToolDef] = [
CREATE_ARTIFACT_TOOL,
LIST_ARTIFACTS_TOOL,
OPEN_ARTIFACT_TOOL,
UPDATE_ARTIFACT_METADATA_TOOL,
LAUNCH_BACKEND_TOOL,
]

# Skill label → tools that skill unlocks. The two build-* skills map to the
# same bundle so jumping straight into a dashboard/backend also unlocks the
# artifact tools without a separate `artifacts` recall.
SKILL_TOOL_BUNDLES: dict[str, list[ToolDef]] = {
"artifacts": ARTIFACT_TOOL_BUNDLE,
"build-html-dashboard": ARTIFACT_TOOL_BUNDLE,
"build-fullstack-backend": ARTIFACT_TOOL_BUNDLE,
}
Loading