Skip to content
Merged
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
189 changes: 180 additions & 9 deletions app/agent_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import asyncio
import os
import re
import shutil
import traceback
import time
Expand Down Expand Up @@ -301,6 +302,11 @@ def __init__(
agent_file_system_path=AGENT_FILE_SYSTEM_PATH,
)

# A2APP claim gate (spec A2APP-PLAN Phase 1 B10): what this run has
# actually written to a Living UI, and how many messages have been
# withheld for misreporting it. Both reset when the run ends.
self._lui_run_writes: Dict[str, list] = {}

# action layer
self.action_library = ActionLibrary(self.llm, db_interface=self.db_interface)

Expand Down Expand Up @@ -1055,8 +1061,141 @@ async def _execute_actions(
is_running_task=True,
)

# A2APP: when the agent writes to a Living UI, the SYSTEM reports what
# actually landed. See spec/A2APP-PLAN.md Phase 1 B10/B11.
self._report_living_ui_writes(session_id, actions_with_input, results)

return self._merge_action_outputs(results)

# Recognises a WRITE through the lui CLI. Reads (list/get) are ignored:
# they change nothing and need no receipt.
_LUI_WRITE = re.compile(
r"cli\.ts\s+(?:data\s+\S+\s+(?P<collection>\S+)\s+(?P<verb>create|update|delete)"
r"|run\s+\S+\s+(?P<op>[\w.\-]+))"
)

def _report_living_ui_writes(
self, session_id: str, actions_with_input: list, results: list
) -> None:
"""Report what a turn changed, IN CRAFTBOT'S VOICE, and refresh the app.

Why the system writes it: in the incident that motivated A2APP the
agent wrote a card with an empty due date, read `"due_date":""` in its
own tool output, and told the user "scheduled for tomorrow". Guarding
the write stops the bad data; it does not stop the false sentence.

Why it is not a separate "System" speaker: it was, and it read badly —
the user saw a grey robot line restating what the assistant then said
again, less precisely ("due tomorrow" against the receipt's "due Fri 31
Jul") and padded with filler. Delivering the fact AS CraftBot removes
the duplication and the extra narration turn, and keeps the guarantee:
the words come from the stored record, not from the model.

One line per turn, not per write, so a turn that changes three things
does not produce three bubbles. (A bulk run spread over many turns
still yields many lines — see A2APP-PLAN for the open case.)

Also the only place `dispatch_living_ui_data_changed` fires on the CLI
path — previously it fired solely from the deprecated `living_ui_http`
action, so agent writes never refreshed the iframe.
"""
try:
session = self.session_manager.get(session_id)
except Exception:
session = None
project_id = getattr(session, "living_ui_project_id", None) if session else None
if not project_id:
return

summaries = []
for (action, params), result in zip(actions_with_input, results):
try:
if getattr(action, "name", None) != "run_shell":
continue
command = str((params or {}).get("command") or "")
match = self._LUI_WRITE.search(command)
if match is None:
continue
summary = self._describe_write(session_id, project_id, match, result)
if summary:
summaries.append(summary)
except Exception as e: # a receipt must never break the turn
logger.debug(f"[A2APP] receipt skipped: {e}")

if not summaries:
return

if self.event_stream_manager:
text = summaries[0] if len(summaries) == 1 else "\n".join(f"• {s}" for s in summaries)
self.event_stream_manager.log(
kind="living_ui_write",
message=text,
event_type=EventType.AGENT_MESSAGE,
display_message=text,
task_id=session_id,
)

try:
from app.living_ui import dispatch_living_ui_data_changed

dispatch_living_ui_data_changed(project_id)
except Exception as e:
logger.debug(f"[A2APP] data-changed dispatch skipped: {e}")

def _describe_write(
self, session_id: str, project_id: str, match, result: dict
) -> Optional[str]:
"""One CLI write result -> one plain sentence, or None if there is
nothing the user needs to read."""
import json as _json

collection = match.group("collection")
verb = match.group("verb")
target = match.group("op") or f"{collection}.{verb}"
stdout = str((result or {}).get("stdout") or "")
stderr = str((result or {}).get("stderr") or "")
failed = (result or {}).get("status") == "error" or (result or {}).get(
"return_code"
) not in (0, None)

# A failure the agent goes on to recover from is NOT an event in the
# user's world — it is an internal retry, and putting it in the chat
# reads like the assistant arguing with itself. The agent still sees it
# (action_end carries the full stderr) and so does anyone who opens the
# actions detail; the conversation stays about what the user asked for.
if failed:
logger.info(f"[A2APP] {target} rejected: {(stderr or stdout).strip()[:200]}")
return None

record = None
try:
parsed = _json.loads(stdout)
if isinstance(parsed, dict) and "id" in parsed:
record = parsed
except Exception:
record = None

summary = f"{target} ok"
if record is not None and collection:
try:
from app.living_ui import get_living_ui_manager
from app.living_ui.agent_view import humanise_write

mgr = get_living_ui_manager()
proj = mgr.get_project(project_id) if mgr else None
base = (proj.backend_url or proj.url) if proj else None
if base:
summary = humanise_write(
base.rstrip("/"), collection, verb or "create", record
)
except Exception as e:
logger.debug(f"[A2APP] could not humanise receipt: {e}")

self._lui_run_writes.setdefault(session_id, []).append(
{"collection": collection, "verb": verb, "record": record, "summary": summary}
)
return summary

def _merge_action_outputs(self, outputs: list) -> dict:
"""
Merge outputs from parallel actions into single response.
Expand Down Expand Up @@ -1103,6 +1242,9 @@ async def _finalize_turn(
run_ends = bool(action_output.get("run_ends", False))

if run_ends:
# The claim gate is scoped to a run: what was written for THIS
# request says nothing about the next one.
self._lui_run_writes.pop(session.id, None)
await self._on_run_end(session, trigger.payload or {})
return

Expand Down Expand Up @@ -1750,19 +1892,48 @@ def _build_living_ui_note(living_ui_project_id: str) -> str:
if mgr:
proj = mgr.get_project(living_ui_project_id)
if proj:
# The DATA MODEL goes in the prompt, not behind a pointer.
# Twice now the agent has ignored "Read LIVING_UI.md", never
# run `lui ops`, and guessed collection names instead
# (`items`, then `tasks`) — and once invented an enum value
# (`priority: "normal"`) it could not have known was wrong.
# Advisory text does not work on a weak model; context does.
schema = None
try:
from app.living_ui.agent_view import schema_block

base = proj.backend_url or proj.url
if base:
schema = schema_block(base.rstrip("/"))
except Exception:
schema = None

model = (
f"Data model (field(type), * = required):\n{schema}\n"
if schema
else f"Data model: run node {_lui_cli} data {proj.path} schema\n"
)
return (
f"[INTERACTING WITH LIVING UI: {proj.name} ({living_ui_project_id})]\n"
f"Project path: {proj.path}\n"
f"Read {proj.path}/LIVING_UI.md for app context.\n"
f"If debugging issues, FIRST read these logs:\n"
f" - {proj.path}/logs/pocketbase.log (server, migrations, crashes)\n"
f" - {proj.path}/logs/frontend_console.log (frontend errors, network failures)\n"
f"To OPERATE the app (read/write data, run its verbs), use the lui CLI via run_shell\n"
f"(preferred over living_ui_http). Use these EXACT absolute commands (the shell's\n"
f"cwd is NOT the repo root — relative paths will fail):\n"
f" node {_lui_cli} ops {proj.path}\n"
f"{model}"
f"Values: dates as ISO or 'tomorrow'/'next monday' (the CLI resolves them);\n"
f"references by name, e.g. --list \"To Do\". Only set fields the user asked for.\n"
f"AFTER A SUCCESSFUL WRITE the user is ALREADY shown exactly what changed, in\n"
f"your voice, generated from the stored record. Do NOT send a message repeating\n"
f"it — end the turn. Send a message only to add something that report does not\n"
f"cover: a failure, a question, an answer to a question, or a summary of many\n"
f"changes.\n"
f"To OPERATE the app, use the lui CLI via run_shell with ABSOLUTE paths\n"
f"(the shell's cwd is NOT the repo root):\n"
f' node {_lui_cli} data {proj.path} <collection> create --field "value"\n'
f' ALWAYS quote values — an unquoted # starts a shell comment and\n'
f' silently drops the rest of the command.\n'
f" node {_lui_cli} data {proj.path} <collection> list --limit 20\n"
f" node {_lui_cli} run {proj.path} <op-name> --param value\n"
f" node {_lui_cli} data {proj.path} <collection> list --limit 20"
f"If debugging, read {proj.path}/logs/pocketbase.log and logs/frontend_console.log.\n"
f"Using the app needs no skill. To CHANGE its code, or import/diagnose one,\n"
f"load the right Living UI skill first (use_skill); list_skills shows all skills."
)
except Exception:
pass
Expand Down
19 changes: 13 additions & 6 deletions app/data/action/living_ui_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,13 @@ async def living_ui_scaffold(input_data: dict) -> dict:
@action(
name="living_ui_notify_ready",
description=(
"Launch, verify, and serve a Living UI project. "
"Call this after building the Living UI code. "
"This action installs dependencies, runs tests, starts the backend and frontend, "
"and notifies the browser. Returns test errors if anything fails."
"Launch or RELAUNCH a Living UI project: installs dependencies, runs the "
"validation gate, restarts backend and frontend, notifies the browser. "
"Call this ONLY after CREATING or CHANGING the app's CODE (migrations, "
"hooks, frontend). An app that is already running does NOT need it — "
"adding, editing or deleting DATA never requires a relaunch, and calling "
"it then rebuilds and restarts a live app for no reason. "
"Returns test errors if anything fails."
),
default=False,
mode="CLI",
Expand Down Expand Up @@ -329,9 +332,13 @@ async def living_ui_notify_ready(input_data: dict) -> dict:
"UI project: a real browser (headless) drives the app "
"feature-by-feature against reference/requirements.md. A clean "
"verdict announces the app to the user — the ONLY way a Living UI "
"build completes. Observed defects return the failure report: fix, "
"BUILD completes. Observed defects return the failure report: fix, "
"relaunch with living_ui_notify_ready, then call this again. "
"Requires the app to be running (living_ui_notify_ready first)."
"Requires the app to be running (living_ui_notify_ready first). "
"ONLY after building or modifying the app's CODE. NEVER after a data "
"change: it drives a real browser and CLICKS through the UI, including "
"buttons that create records, so running it against an app holding the "
"user's data can alter that data."
),
default=False,
mode="CLI",
Expand Down
Loading
Loading