Skip to content

Commit cf8d246

Browse files
authored
Merge pull request #397 from CraftOS-dev/livingui-third-party-fix
Add the Factory — supervision for weak-model app builds
2 parents 5f3bea8 + 592dfab commit cf8d246

54 files changed

Lines changed: 9128 additions & 615 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/agent_base.py

Lines changed: 226 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
import asyncio
2626
import os
27+
import re
2728
import shutil
2829
import traceback
2930
import time
@@ -301,6 +302,11 @@ def __init__(
301302
agent_file_system_path=AGENT_FILE_SYSTEM_PATH,
302303
)
303304

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+
304310
# action layer
305311
self.action_library = ActionLibrary(self.llm, db_interface=self.db_interface)
306312

@@ -605,6 +611,23 @@ async def react(self, trigger: Trigger) -> None:
605611
# for the model.
606612
self._log_trigger_claim(trigger, session_id)
607613

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+
608631
# ----- Deferred user-message stream write -----
609632
# User messages enter the event stream HERE — at the start of
610633
# their own turn — not at arrival. This keeps the stream
@@ -1055,8 +1078,142 @@ async def _execute_actions(
10551078
is_running_task=True,
10561079
)
10571080

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+
10581086
return self._merge_action_outputs(results)
10591087

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+
10601217
def _merge_action_outputs(self, outputs: list) -> dict:
10611218
"""
10621219
Merge outputs from parallel actions into single response.
@@ -1103,6 +1260,23 @@ async def _finalize_turn(
11031260
run_ends = bool(action_output.get("run_ends", False))
11041261

11051262
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}")
11061280
await self._on_run_end(session, trigger.payload or {})
11071281
return
11081282

@@ -1750,19 +1924,62 @@ def _build_living_ui_note(living_ui_project_id: str) -> str:
17501924
if mgr:
17511925
proj = mgr.get_project(living_ui_project_id)
17521926
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 = ""
17531961
return (
17541962
f"[INTERACTING WITH LIVING UI: {proj.name} ({living_ui_project_id})]\n"
17551963
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"
17641979
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."
17661983
)
17671984
except Exception:
17681985
pass

app/data/action/integrations/google_workspace/gmail_actions.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
input_schema={
1515
"to": {
1616
"type": "string",
17-
"description": "Recipient email address.",
17+
"description": (
18+
"Recipient email address. OMIT to send to the user's own "
19+
"address (the connected account) — never store or guess the "
20+
"user's email."
21+
),
1822
"example": "user@example.com",
1923
},
2024
"subject": {
@@ -45,7 +49,8 @@ def send_gmail(input_data: dict) -> dict:
4549
unwrap_envelope=True,
4650
success_message="Email sent.",
4751
fail_message="Failed to send email.",
48-
to=input_data["to"],
52+
# Omitted/empty `to` → the client sends to the account owner.
53+
to=input_data.get("to"),
4954
subject=input_data["subject"],
5055
body=input_data["body"],
5156
attachments=input_data.get("attachments"),

0 commit comments

Comments
 (0)