mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[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:
@@ -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
|
||||
Reference in New Issue
Block a user