[eric] apps: name the user's apps when none is selected, so the agent can point at the selection step instead of denying they exist

This commit is contained in:
ciregenz
2026-07-31 15:29:07 -07:00
parent b5d0461828
commit 91982a2278
3 changed files with 153 additions and 0 deletions
@@ -17,6 +17,7 @@ from backend.apps.agents.manager.prompt.prompt_context import (
build_installed_skills_catalog,
build_mcp_registry_summary,
build_selected_app_context,
build_unselected_app_context,
build_selected_settings_context,
compose_system_prompt,
)
@@ -122,6 +123,9 @@ def compose_turn_system_prompt(
# App cards the user picked via the dashboard element picker: give the agent each app's on-disk path + meta + SKILL.md pointer so it can edit them in place (the dashboard card's runtime live-reloads). Additive and independent of view-builder mode above.
app_ctx = build_selected_app_context(selected_app_output_ids)
# Nothing picked: name the apps anyway so the agent can point the user at the selection step instead of acting like their app does not exist.
if not app_ctx:
app_ctx = build_unselected_app_context()
if app_ctx:
composed_prompt = f"{composed_prompt}\n\n{app_ctx}" if composed_prompt else app_ctx
@@ -132,6 +132,39 @@ def build_browser_context(dashboard_id: Optional[str], selected_browser_ids: Opt
return "\n".join(lines)
@typechecked
def build_unselected_app_context() -> Optional[str]:
"""Name the user's existing apps when none is selected.
Selecting a card is what grants edit access, but a user who has not learned that ritual just
sees an agent that appears not to know their app exists, and the agent cannot tell them what to
do because it was never told the app was there either. Names only, no paths: enough to say
"select it and I can edit it", not enough to start editing something the user did not point at.
"""
from backend.apps.outputs.workspace_io import load_all
try:
apps = [o for o in load_all() if o.workspace_id and not getattr(o, "deleted_at", None)]
except Exception:
return None
if not apps:
return None
names = ", ".join(f'"{o.name or "Untitled App"}"' for o in apps[:P_UNSELECTED_APP_CAP])
more = "" if len(apps) <= P_UNSELECTED_APP_CAP else f", and {len(apps) - P_UNSELECTED_APP_CAP} more"
return (
"<available_apps>\n"
f"The user has these Apps on their dashboard: {names}{more}.\n"
"You do NOT have access to their files right now. If the user asks you to change, look at, "
"or use one of them, say so plainly and tell them to click the App card to select it, then "
"resend. Do not guess at file paths, do not scaffold a replacement, and do not claim the app "
"does not exist.\n"
"</available_apps>"
)
# A long list burns prompt tokens for no gain; past a handful the agent only needs to know apps exist.
P_UNSELECTED_APP_CAP = 12
@typechecked
def build_selected_app_context(selected_app_output_ids: Optional[List[str]]) -> Optional[str]:
"""Build a context block for dashboard App cards the user selected to edit.
@@ -0,0 +1,116 @@
"""An agent that cannot see an app must still know it exists.
Selecting an App card is what grants edit access, and that gate is deliberate. But a user who has
not learned the ritual just sees an agent behaving as though their app was never built, and the
agent cannot explain the problem because it was never told the app was there either. So with
nothing selected we name the apps and nothing more: enough to say "select it and I can edit it",
not enough to start editing something the user never pointed at.
Run:
cd backend && .venv/bin/python -m pytest tests/test_unselected_app_context.py -v
"""
from __future__ import annotations
from backend.apps.agents.manager.prompt import prompt_context
from backend.apps.outputs.models import Output
def p_app(name: str, workspace_id: str = "ws") -> Output:
return Output(name=name, workspace_id=workspace_id)
def test_names_the_apps_when_nothing_is_selected(monkeypatch):
monkeypatch.setattr(prompt_context, "load_all", lambda: [p_app("Budget Tracker")], raising=False)
monkeypatch.setattr(
"backend.apps.outputs.workspace_io.load_all",
lambda: [p_app("Budget Tracker"), p_app("Habit Log")],
)
out = prompt_context.build_unselected_app_context()
assert out is not None
assert "Budget Tracker" in out and "Habit Log" in out
def test_tells_the_agent_to_ask_for_a_selection(monkeypatch):
"""The whole point: the agent has to hand the user the next step, not stonewall them."""
monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: [p_app("Budget Tracker")])
out = prompt_context.build_unselected_app_context() or ""
assert "select" in out.lower()
assert "does not exist" in out, "must explicitly forbid claiming the app is missing"
def test_leaks_no_paths(monkeypatch):
"""Names only. A path here would let the agent edit an app the user never pointed at, which is
exactly the access the selection gate exists to withhold."""
monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: [p_app("Budget Tracker")])
out = prompt_context.build_unselected_app_context() or ""
assert "/" not in out.replace("</available_apps>", ""), "no filesystem paths may appear"
assert "workspace" not in out.lower()
def test_silent_when_the_user_has_no_apps(monkeypatch):
monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: [])
assert prompt_context.build_unselected_app_context() is None
def test_skips_apps_with_no_workspace(monkeypatch):
"""A row without a workspace is not a real app the user can select."""
monkeypatch.setattr(
"backend.apps.outputs.workspace_io.load_all",
lambda: [Output(name="Ghost", workspace_id=None)],
)
assert prompt_context.build_unselected_app_context() is None
def test_caps_a_long_list(monkeypatch):
many = [p_app(f"App {i}") for i in range(30)]
monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: many)
out = prompt_context.build_unselected_app_context() or ""
assert "App 0" in out
assert "App 29" not in out, "an uncapped list burns prompt tokens on every turn"
assert "more" in out, "the user must still learn there are others"
def test_a_broken_store_does_not_break_the_turn(monkeypatch):
def p_boom():
raise OSError("disk gone")
monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", p_boom)
assert prompt_context.build_unselected_app_context() is None
def test_selection_still_wins(monkeypatch):
"""With a real selection the rich block must be used; this fallback must not shadow it."""
monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: [p_app("Budget Tracker")])
selected = prompt_context.build_selected_app_context(None)
assert selected is None, "no ids means no rich block, which is what triggers the fallback"
def test_the_block_actually_reaches_the_composed_prompt(monkeypatch):
"""The one that matters. Every test above passes even with the builder unwired from the prompt,
so without this the feature could ship doing nothing at all."""
from backend.apps.agents.core.models import AgentSession
from backend.apps.agents.manager.prompt.compose_turn_system_prompt import compose_turn_system_prompt
monkeypatch.setattr("backend.apps.outputs.workspace_io.load_all", lambda: [p_app("Budget Tracker")])
session = AgentSession(id="s1", name="t", model="sonnet", mode="agent")
composed = compose_turn_system_prompt(
session=session,
mode_sys_prompt=None,
default_system_prompt=None,
selected_browser_ids=None,
selected_app_output_ids=None,
selected_setting_ids=None,
) or ""
assert "Budget Tracker" in composed
assert "<available_apps>" in composed