|
24 | 24 |
|
25 | 25 | import asyncio |
26 | 26 | import os |
| 27 | +import re |
27 | 28 | import shutil |
28 | 29 | import traceback |
29 | 30 | import time |
@@ -301,6 +302,11 @@ def __init__( |
301 | 302 | agent_file_system_path=AGENT_FILE_SYSTEM_PATH, |
302 | 303 | ) |
303 | 304 |
|
| 305 | + # A2APP claim gate (spec A2APP-PLAN Phase 1 B10): what this run has |
| 306 | + # actually written to a Living UI, and how many messages have been |
| 307 | + # withheld for misreporting it. Both reset when the run ends. |
| 308 | + self._lui_run_writes: Dict[str, list] = {} |
| 309 | + |
304 | 310 | # action layer |
305 | 311 | self.action_library = ActionLibrary(self.llm, db_interface=self.db_interface) |
306 | 312 |
|
@@ -605,6 +611,23 @@ async def react(self, trigger: Trigger) -> None: |
605 | 611 | # for the model. |
606 | 612 | self._log_trigger_claim(trigger, session_id) |
607 | 613 |
|
| 614 | + # FACTORY: a mission's RUN has actually started (vs. merely being |
| 615 | + # queued). Without this marker, a run that later ends on a |
| 616 | + # run_continuation trigger (which carries no mission id) could not |
| 617 | + # be attributed to its mission — and a surrendered mission would |
| 618 | + # silently suppress redispatch (observed: done machine with |
| 619 | + # mission_id still set). |
| 620 | + try: |
| 621 | + mission_id = (trigger.payload or {}).get("factory_mission_id") if trigger else None |
| 622 | + if mission_id: |
| 623 | + from app.factory.host_craftbot import get_factory_host |
| 624 | + |
| 625 | + project_id = (trigger.payload or {}).get("project_id") |
| 626 | + if project_id: |
| 627 | + get_factory_host().mission_run_started(str(project_id), str(mission_id)) |
| 628 | + except Exception as e: |
| 629 | + logger.debug(f"[FACTORY] mission-start marker failed: {e}") |
| 630 | + |
608 | 631 | # ----- Deferred user-message stream write ----- |
609 | 632 | # User messages enter the event stream HERE — at the start of |
610 | 633 | # their own turn — not at arrival. This keeps the stream |
@@ -1055,8 +1078,142 @@ async def _execute_actions( |
1055 | 1078 | is_running_task=True, |
1056 | 1079 | ) |
1057 | 1080 |
|
| 1081 | + # A2APP: when the agent writes to a Living UI, the SYSTEM reports what |
| 1082 | + # actually landed. See spec/A2APP-PLAN.md Phase 1 B10/B11. |
| 1083 | + self._report_living_ui_writes(session_id, actions_with_input, results) |
| 1084 | + |
| 1085 | + |
1058 | 1086 | return self._merge_action_outputs(results) |
1059 | 1087 |
|
| 1088 | + # Recognises a WRITE through the lui CLI. Reads (list/get) are ignored: |
| 1089 | + # they change nothing and need no receipt. |
| 1090 | + _LUI_WRITE = re.compile( |
| 1091 | + r"cli\.ts\s+(?:data\s+\S+\s+(?P<collection>\S+)\s+(?P<verb>create|update|delete)" |
| 1092 | + r"|run\s+\S+\s+(?P<op>[\w.\-]+))" |
| 1093 | + ) |
| 1094 | + |
| 1095 | + def _report_living_ui_writes( |
| 1096 | + self, session_id: str, actions_with_input: list, results: list |
| 1097 | + ) -> None: |
| 1098 | + """Report what a turn changed, IN CRAFTBOT'S VOICE, and refresh the app. |
| 1099 | +
|
| 1100 | + Why the system writes it: in the incident that motivated A2APP the |
| 1101 | + agent wrote a card with an empty due date, read `"due_date":""` in its |
| 1102 | + own tool output, and told the user "scheduled for tomorrow". Guarding |
| 1103 | + the write stops the bad data; it does not stop the false sentence. |
| 1104 | +
|
| 1105 | + Why it is not a separate "System" speaker: it was, and it read badly — |
| 1106 | + the user saw a grey robot line restating what the assistant then said |
| 1107 | + again, less precisely ("due tomorrow" against the receipt's "due Fri 31 |
| 1108 | + Jul") and padded with filler. Delivering the fact AS CraftBot removes |
| 1109 | + the duplication and the extra narration turn, and keeps the guarantee: |
| 1110 | + the words come from the stored record, not from the model. |
| 1111 | +
|
| 1112 | + One line per turn, not per write, so a turn that changes three things |
| 1113 | + does not produce three bubbles. (A bulk run spread over many turns |
| 1114 | + still yields many lines — see A2APP-PLAN for the open case.) |
| 1115 | +
|
| 1116 | + Also the only place `dispatch_living_ui_data_changed` fires on the CLI |
| 1117 | + path — previously it fired solely from the deprecated `living_ui_http` |
| 1118 | + action, so agent writes never refreshed the iframe. |
| 1119 | + """ |
| 1120 | + try: |
| 1121 | + session = self.session_manager.get(session_id) |
| 1122 | + except Exception: |
| 1123 | + session = None |
| 1124 | + project_id = getattr(session, "living_ui_project_id", None) if session else None |
| 1125 | + if not project_id: |
| 1126 | + return |
| 1127 | + |
| 1128 | + summaries = [] |
| 1129 | + for (action, params), result in zip(actions_with_input, results): |
| 1130 | + try: |
| 1131 | + if getattr(action, "name", None) != "run_shell": |
| 1132 | + continue |
| 1133 | + command = str((params or {}).get("command") or "") |
| 1134 | + match = self._LUI_WRITE.search(command) |
| 1135 | + if match is None: |
| 1136 | + continue |
| 1137 | + summary = self._describe_write(session_id, project_id, match, result) |
| 1138 | + if summary: |
| 1139 | + summaries.append(summary) |
| 1140 | + except Exception as e: # a receipt must never break the turn |
| 1141 | + logger.debug(f"[A2APP] receipt skipped: {e}") |
| 1142 | + |
| 1143 | + if not summaries: |
| 1144 | + return |
| 1145 | + |
| 1146 | + if self.event_stream_manager: |
| 1147 | + text = summaries[0] if len(summaries) == 1 else "\n".join(f"• {s}" for s in summaries) |
| 1148 | + self.event_stream_manager.log( |
| 1149 | + kind="living_ui_write", |
| 1150 | + message=text, |
| 1151 | + event_type=EventType.AGENT_MESSAGE, |
| 1152 | + display_message=text, |
| 1153 | + task_id=session_id, |
| 1154 | + ) |
| 1155 | + |
| 1156 | + try: |
| 1157 | + from app.living_ui import dispatch_living_ui_data_changed |
| 1158 | + |
| 1159 | + dispatch_living_ui_data_changed(project_id) |
| 1160 | + except Exception as e: |
| 1161 | + logger.debug(f"[A2APP] data-changed dispatch skipped: {e}") |
| 1162 | + |
| 1163 | + def _describe_write( |
| 1164 | + self, session_id: str, project_id: str, match, result: dict |
| 1165 | + ) -> Optional[str]: |
| 1166 | + """One CLI write result -> one plain sentence, or None if there is |
| 1167 | + nothing the user needs to read.""" |
| 1168 | + import json as _json |
| 1169 | + |
| 1170 | + collection = match.group("collection") |
| 1171 | + verb = match.group("verb") |
| 1172 | + target = match.group("op") or f"{collection}.{verb}" |
| 1173 | + stdout = str((result or {}).get("stdout") or "") |
| 1174 | + stderr = str((result or {}).get("stderr") or "") |
| 1175 | + failed = (result or {}).get("status") == "error" or (result or {}).get( |
| 1176 | + "return_code" |
| 1177 | + ) not in (0, None) |
| 1178 | + |
| 1179 | + # A failure the agent goes on to recover from is NOT an event in the |
| 1180 | + # user's world — it is an internal retry, and putting it in the chat |
| 1181 | + # reads like the assistant arguing with itself. The agent still sees it |
| 1182 | + # (action_end carries the full stderr) and so does anyone who opens the |
| 1183 | + # actions detail; the conversation stays about what the user asked for. |
| 1184 | + if failed: |
| 1185 | + logger.info(f"[A2APP] {target} rejected: {(stderr or stdout).strip()[:200]}") |
| 1186 | + return None |
| 1187 | + |
| 1188 | + record = None |
| 1189 | + try: |
| 1190 | + parsed = _json.loads(stdout) |
| 1191 | + if isinstance(parsed, dict) and "id" in parsed: |
| 1192 | + record = parsed |
| 1193 | + except Exception: |
| 1194 | + record = None |
| 1195 | + |
| 1196 | + summary = f"{target} ok" |
| 1197 | + if record is not None and collection: |
| 1198 | + try: |
| 1199 | + from app.living_ui import get_living_ui_manager |
| 1200 | + from app.living_ui.agent_view import humanise_write |
| 1201 | + |
| 1202 | + mgr = get_living_ui_manager() |
| 1203 | + proj = mgr.get_project(project_id) if mgr else None |
| 1204 | + base = (proj.backend_url or proj.url) if proj else None |
| 1205 | + if base: |
| 1206 | + summary = humanise_write( |
| 1207 | + base.rstrip("/"), collection, verb or "create", record |
| 1208 | + ) |
| 1209 | + except Exception as e: |
| 1210 | + logger.debug(f"[A2APP] could not humanise receipt: {e}") |
| 1211 | + |
| 1212 | + self._lui_run_writes.setdefault(session_id, []).append( |
| 1213 | + {"collection": collection, "verb": verb, "record": record, "summary": summary} |
| 1214 | + ) |
| 1215 | + return summary |
| 1216 | + |
1060 | 1217 | def _merge_action_outputs(self, outputs: list) -> dict: |
1061 | 1218 | """ |
1062 | 1219 | Merge outputs from parallel actions into single response. |
@@ -1103,6 +1260,23 @@ async def _finalize_turn( |
1103 | 1260 | run_ends = bool(action_output.get("run_ends", False)) |
1104 | 1261 |
|
1105 | 1262 | if run_ends: |
| 1263 | + # The claim gate is scoped to a run: what was written for THIS |
| 1264 | + # request says nothing about the next one. |
| 1265 | + self._lui_run_writes.pop(session.id, None) |
| 1266 | + # FACTORY Phase 1 (closes I6): if this run belonged to a Living UI |
| 1267 | + # build and the machine says work should be in flight but isn't, |
| 1268 | + # the machine redispatches a fresh mission. The agent surrendering |
| 1269 | + # is no longer a terminal event — the system carries the arc. |
| 1270 | + try: |
| 1271 | + lui_project = getattr(session, "living_ui_project_id", None) |
| 1272 | + if lui_project: |
| 1273 | + from app.factory.host_craftbot import get_factory_host |
| 1274 | + |
| 1275 | + get_factory_host().on_run_end( |
| 1276 | + lui_project, (trigger.payload or {}) if trigger else {} |
| 1277 | + ) |
| 1278 | + except Exception as e: |
| 1279 | + logger.debug(f"[FACTORY] run-end hook failed: {e}") |
1106 | 1280 | await self._on_run_end(session, trigger.payload or {}) |
1107 | 1281 | return |
1108 | 1282 |
|
@@ -1750,19 +1924,62 @@ def _build_living_ui_note(living_ui_project_id: str) -> str: |
1750 | 1924 | if mgr: |
1751 | 1925 | proj = mgr.get_project(living_ui_project_id) |
1752 | 1926 | if proj: |
| 1927 | + # The DATA MODEL goes in the prompt, not behind a pointer. |
| 1928 | + # Twice now the agent has ignored "Read LIVING_UI.md", never |
| 1929 | + # run `lui ops`, and guessed collection names instead |
| 1930 | + # (`items`, then `tasks`) — and once invented an enum value |
| 1931 | + # (`priority: "normal"`) it could not have known was wrong. |
| 1932 | + # Advisory text does not work on a weak model; context does. |
| 1933 | + schema = None |
| 1934 | + try: |
| 1935 | + from app.living_ui.agent_view import schema_block |
| 1936 | + |
| 1937 | + base = proj.backend_url or proj.url |
| 1938 | + if base: |
| 1939 | + schema = schema_block(base.rstrip("/")) |
| 1940 | + except Exception: |
| 1941 | + schema = None |
| 1942 | + |
| 1943 | + model = ( |
| 1944 | + f"Data model (field(type), * = required):\n{schema}\n" |
| 1945 | + if schema |
| 1946 | + else f"Data model: run node {_lui_cli} data {proj.path} schema\n" |
| 1947 | + ) |
| 1948 | + # Same principle as the schema: capabilities go IN the |
| 1949 | + # prompt. Three builds stubbed the user's email feature |
| 1950 | + # around an invented SMTP requirement because nothing in |
| 1951 | + # context said send_gmail exists. |
| 1952 | + caps = "" |
| 1953 | + try: |
| 1954 | + from app.living_ui.agent_view import capability_block |
| 1955 | + |
| 1956 | + cap = capability_block() |
| 1957 | + if cap: |
| 1958 | + caps = cap + "\n" |
| 1959 | + except Exception: |
| 1960 | + caps = "" |
1753 | 1961 | return ( |
1754 | 1962 | f"[INTERACTING WITH LIVING UI: {proj.name} ({living_ui_project_id})]\n" |
1755 | 1963 | f"Project path: {proj.path}\n" |
1756 | | - f"Read {proj.path}/LIVING_UI.md for app context.\n" |
1757 | | - f"If debugging issues, FIRST read these logs:\n" |
1758 | | - f" - {proj.path}/logs/pocketbase.log (server, migrations, crashes)\n" |
1759 | | - f" - {proj.path}/logs/frontend_console.log (frontend errors, network failures)\n" |
1760 | | - f"To OPERATE the app (read/write data, run its verbs), use the lui CLI via run_shell\n" |
1761 | | - f"(preferred over living_ui_http). Use these EXACT absolute commands (the shell's\n" |
1762 | | - f"cwd is NOT the repo root — relative paths will fail):\n" |
1763 | | - f" node {_lui_cli} ops {proj.path}\n" |
| 1964 | + f"{model}" |
| 1965 | + f"{caps}" |
| 1966 | + f"Values: dates as ISO or 'tomorrow'/'next monday' (the CLI resolves them);\n" |
| 1967 | + f"references by name, e.g. --list \"To Do\". Only set fields the user asked for.\n" |
| 1968 | + f"AFTER A SUCCESSFUL WRITE the user is ALREADY shown exactly what changed, in\n" |
| 1969 | + f"your voice, generated from the stored record. Do NOT send a message repeating\n" |
| 1970 | + f"it — end the turn. Send a message only to add something that report does not\n" |
| 1971 | + f"cover: a failure, a question, an answer to a question, or a summary of many\n" |
| 1972 | + f"changes.\n" |
| 1973 | + f"To OPERATE the app, use the lui CLI via run_shell with ABSOLUTE paths\n" |
| 1974 | + f"(the shell's cwd is NOT the repo root):\n" |
| 1975 | + f' node {_lui_cli} data {proj.path} <collection> create --field "value"\n' |
| 1976 | + f' ALWAYS quote values — an unquoted # starts a shell comment and\n' |
| 1977 | + f' silently drops the rest of the command.\n' |
| 1978 | + f" node {_lui_cli} data {proj.path} <collection> list --limit 20\n" |
1764 | 1979 | f" node {_lui_cli} run {proj.path} <op-name> --param value\n" |
1765 | | - f" node {_lui_cli} data {proj.path} <collection> list --limit 20" |
| 1980 | + f"If debugging, read {proj.path}/logs/pocketbase.log and logs/frontend_console.log.\n" |
| 1981 | + f"Using the app needs no skill. To CHANGE its code, or import/diagnose one,\n" |
| 1982 | + f"load the right Living UI skill first (use_skill); list_skills shows all skills." |
1766 | 1983 | ) |
1767 | 1984 | except Exception: |
1768 | 1985 | pass |
|
0 commit comments