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
26 changes: 26 additions & 0 deletions backend/api/intents_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,31 @@ async def save_recipe(node_id, name):
await bus.publish("notification")
return clean_name

async def delete_recipe(name):
"""stage 8.7: rounds out the recipe lifecycle - saveRecipe already
creates one from a finished build, but nothing could ever remove
one. Mirrors save_recipe's own name-guard against built-ins and its
own settings.get_recipes()/set_recipes() replace-the-whole-list
posture."""
from backend.builder import BUILT_IN_RECIPES

clean_name = str(name or "").strip()
if any(r["name"] == clean_name for r in BUILT_IN_RECIPES):
notifications.show(f'"{clean_name}" is a built-in recipe - it cannot be deleted.', "warning")
await bus.publish("notification")
return False
settings = agent_dispatcher._settings_manager
existing = settings.get_recipes()
remaining = [r for r in existing if r["name"] != clean_name]
if len(remaining) == len(existing):
notifications.show(f'No saved recipe named "{clean_name}".', "info")
await bus.publish("notification")
return False
settings.set_recipes(remaining)
notifications.show(f'Deleted recipe "{clean_name}".', "info")
await bus.publish("notification")
return True

async def start_execution(node_id):
node = document.nodes.get(node_id)
if node is None or not isinstance(node.state, PlanState):
Expand Down Expand Up @@ -202,4 +227,5 @@ async def set_plan_steps(node_id, steps):
bus.register_intent("builder", "denyTool", deny_tool)
bus.register_intent("builder", "listRecipes", list_recipes)
bus.register_intent("builder", "saveRecipe", save_recipe)
bus.register_intent("builder", "deleteRecipe", delete_recipe)
bus.register_intent("scene", "setPlanSteps", set_plan_steps)
83 changes: 77 additions & 6 deletions backend/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,27 @@

_APPROVAL_SUMMARY_CAP = 400

# stage 8.7: the activity log's own caps. A build is bounded at 50 steps x
# _STEP_TURN_CAP turns, so nothing else bounds how many rows a pathological
# replan loop could otherwise append to a plan node's wire dict (a whole-
# node diff per graph.py's take_dirty_patch_ops) - the ring buffer is the
# backstop. The summary cap is tighter than approval's own 400: approval
# summaries are shown one at a time, activity rows are shown many at once.
_ACTIVITY_CAP = 100
_ACTIVITY_SUMMARY_CAP = 200
# review-fix: a tool CALL's name has no upstream length validation - it is
# whatever a provider's tool-call parsing extracted from the model's own
# output verbatim (e.g. providers/ollama_provider.py's _extract_tool_calls),
# so a malformed/hallucinating turn (most reachable with a local model) can
# emit an arbitrarily large one. Every OTHER field this row stores is
# capped; leaving `tool` uncapped would let exactly that turn defeat the
# same wire/session-size bound the ring buffer and summary cap exist for.
_ACTIVITY_TOOL_NAME_CAP = 80

# stage 8.7: which terminal/pause statuses notify, and with what severity -
# keyed by the exact status string _land() writes to builder_status.
_LAND_NOTIFICATION_KINDS = {"done": "success", "failed": "error", "paused": "warning"}

PLAN_SCHEMA = {
"type": "object",
"properties": {
Expand Down Expand Up @@ -368,11 +389,42 @@ def plan_steps_for_goal(goal: str, *, runtime=None, settings_manager=None) -> li

# -- the executor loop -------------------------------------------------------

def _truncate(text: str, cap: int) -> str:
return text if len(text) <= cap else text[:cap] + "…"


def _approval_summary(call: ToolCall) -> str:
args = json.dumps(call.arguments, sort_keys=True, ensure_ascii=False)
if len(args) > _APPROVAL_SUMMARY_CAP:
args = args[:_APPROVAL_SUMMARY_CAP] + "…"
return f"{call.name} {args}"
return f"{call.name} {_truncate(args, _APPROVAL_SUMMARY_CAP)}"


def _activity_summary(call: ToolCall, result: ToolResult) -> str:
"""What a build's activity row shows for one invoked call. An error
(a real failure OR an approval denial - invoke() returns denial as
ToolResult(is_error=True, content="...was denied approval.") and the
two are not otherwise distinguishable here) shows the tool's own
result text, since that already says exactly what happened. A success
shows the call's arguments instead - the result of, say,
graph.read_subgraph is the interesting part to the MODEL, not to a
human scanning what the build did."""
text = result.content if result.is_error else json.dumps(call.arguments, sort_keys=True, ensure_ascii=False)
return _truncate(text, _ACTIVITY_SUMMARY_CAP)


def _log_activity(node, *, tool: str, summary: str, outcome: str, step_id: str, elapsed_ms: int) -> None:
"""Appends one row to the plan node's activity log - see PlanState's
own docstring for why this is deliberately untouched by undo. Trims
from the front (oldest first) once the ring buffer's cap is exceeded."""
activity = node.state.builder_activity
activity.append({
"tool": _truncate(tool, _ACTIVITY_TOOL_NAME_CAP),
"summary": summary,
"outcome": outcome,
"stepId": step_id,
"elapsedMs": elapsed_ms,
})
if len(activity) > _ACTIVITY_CAP:
del activity[: len(activity) - _ACTIVITY_CAP]


def _rough_token_count(text: str) -> int:
Expand Down Expand Up @@ -532,6 +584,16 @@ async def _land(status: str, detail: str) -> None:
if node.pending_request_id == request_id:
node.pending_request_id = None
await bus.publish("scene")
# stage 8.7: a build that lands while the user is elsewhere on the
# canvas (or the app is unfocused) must not announce nothing.
# "stopped" is excluded - the user just clicked Stop and is
# necessarily present, so a notification for an action they took
# themselves is noise. "interrupted" never reaches this function
# (it is a session_load-time normalization, not a run outcome).
kind = _LAND_NOTIFICATION_KINDS.get(status)
if kind is not None and notifications is not None and detail:
notifications.show(detail, kind)
await bus.publish("notification")

node.state.builder_status = "running"
node.state.builder_status_detail = ""
Expand Down Expand Up @@ -657,7 +719,16 @@ async def _land(status: str, detail: str) -> None:
else:
await _land("paused", breach + " Raise the budget and resume to continue.")
return
call_started = time.monotonic()
result = await registry.invoke(call, ctx)
# stage 8.7: every invoked call, including the four
# in-band control tools - hiding those would make the
# log lie about how many turns the build actually took.
_log_activity(
node, tool=call.name, summary=_activity_summary(call, result),
outcome="error" if result.is_error else "ok",
step_id=step["id"], elapsed_ms=max(0, round((time.monotonic() - call_started) * 1000)),
)
messages.append({
"role": "tool", "tool_call_id": call.id,
"name": call.name, "content": result.content,
Expand Down Expand Up @@ -715,10 +786,10 @@ async def _land(status: str, detail: str) -> None:
# the timeout path above so resume doesn't skip the in-flight step.
if step is not None and step.get("status") == "running":
step["status"] = "pending"
# _land("failed", ...) now sends this failure's own notification
# (see _LAND_NOTIFICATION_KINDS) - a second one here would be a
# duplicate banner for the same event.
await _land("failed", f"Build failed: {exc} — resume to retry.")
if notifications is not None:
notifications.show(f"Build failed: {exc}", "error")
await bus.publish("notification")


def _apply_replan(document, node, controls: BuilderControls, run_id: str) -> None:
Expand Down
25 changes: 25 additions & 0 deletions backend/domain/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,31 @@ def _restore(live: dict, snapshot: dict) -> None:
restored.state.builder_awaiting_tool_approval = False
restored.state.builder_approval_tool_name = ""
restored.state.builder_approval_summary = ""
# review-fix (stage 8.7): builder_activity is documented as run
# TELEMETRY, not reversible document content (PlanState's own
# docstring: "deliberately left untouched by an undo, so the
# record of what happened survives reverting what happened") -
# unlike plan_steps, which genuinely IS reversible content
# (scene/setPlanSteps is A-classified for exactly that reason).
# A command recorded MID-RUN (builderReplan fires on every
# builder.replan call, not just once at the run's start)
# snapshots the node with whatever activity existed at THAT
# instant; restoring that snapshot verbatim would silently erase
# every row logged afterward the moment the command is inverted
# - the same phantom-state class of hazard pending_request_id's
# unconditional reset above already guards against, just for a
# different field. Carries the CURRENT node's activity log
# forward instead of trusting the snapshot's stale copy - `live`
# still holds the pre-restore node at this point, one line
# before it is replaced. A node that does not currently exist
# (this restore is recreating a deleted one) has no "current" to
# preserve, so the snapshot's own activity is used as-is - the
# only case where trusting the snapshot is correct.
current = live.get(key)
if isinstance(getattr(restored, "state", None), PlanState) and isinstance(
getattr(current, "state", None), PlanState,
):
restored.state.builder_activity = current.state.builder_activity
live[key] = restored


Expand Down
13 changes: 13 additions & 0 deletions backend/domain/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -2346,6 +2346,19 @@ def _node_wire(self, n: SceneNode) -> dict[str, Any]:
]
if isinstance(n.state, PlanState) else []
),
# ADR-008 stage 8.7: the run's own activity log - see PlanState's
# docstring for why this is untouched by undo.
"builderActivity": (
[
{
"tool": a["tool"], "summary": a["summary"],
"outcome": a["outcome"], "stepId": a["stepId"],
"elapsedMs": a["elapsedMs"],
}
for a in n.state.builder_activity
]
if isinstance(n.state, PlanState) else []
),
"builderStatus": n.state.builder_status if isinstance(n.state, PlanState) else "",
"builderMode": n.state.builder_mode if isinstance(n.state, PlanState) else "",
"builderRunId": n.state.builder_run_id if isinstance(n.state, PlanState) else "",
Expand Down
15 changes: 15 additions & 0 deletions backend/domain/node_states.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,20 @@ class PlanState(NodeState):
own precedent directly above: the wire layer owns the typed row
(PlanStepRow in contracts/), the domain keeps the flexible shape.

`activity` (stage 8.7) items are plain dicts {"tool": str, "summary":
str, "outcome": "ok"|"error", "stepId": str, "elapsedMs": int} - one
row per tool the loop invoked, in call order, written by builder.py's
_log_activity at its single registry.invoke() choke point. This is the
build's own visible record of WHAT IT DID - distinct from undo (undo_run
reverts document MUTATIONS; a build's activity log is run telemetry and
is deliberately left untouched by an undo, so the record of what
happened survives reverting what happened) and distinct from a chat
node's tool_invocations (that field holds one TURN's calls for a
completed reply; this holds a whole BUILD's calls across every turn and
every step). A capped ring buffer (see builder.py's _ACTIVITY_CAP) -
a build is bounded at 50 steps x 8 turns, so nothing else bounds this
list's growth.

`builder_status` is the loop's own state machine:
draft -> planning -> awaiting_start -> running <-> awaiting_approval
running -> paused (budget breach; resumable)
Expand All @@ -797,6 +811,7 @@ class PlanState(NodeState):

plan_goal: str = ""
plan_steps: list[dict[str, Any]] = field(default_factory=list)
builder_activity: list[dict[str, Any]] = field(default_factory=list)
builder_status: str = "draft"
builder_mode: str = "copilot" # "copilot" | "autopilot"
builder_run_id: str = ""
Expand Down
20 changes: 20 additions & 0 deletions backend/session_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,25 @@ def _restore_plan_payload(payload: dict[str, Any]) -> SceneNode:
"detail": str(raw.get("detail", "")),
})
mode = str(payload.get("builder_mode", "copilot") or "copilot")
activity = []
for raw in payload.get("activity") or []:
if isinstance(raw, dict) and raw.get("tool"):
# review-fix: elapsedMs is untrusted input from a saved file
# (hand-edited, or written by an older/different format) - a
# non-numeric value must degrade to 0, the same tolerance every
# other field on this row already gets via str(), not crash the
# whole session load the way a bare int() would.
try:
elapsed_ms = int(raw.get("elapsedMs", 0) or 0)
except (TypeError, ValueError):
elapsed_ms = 0
activity.append({
"tool": str(raw.get("tool", "")),
"summary": str(raw.get("summary", "")),
"outcome": str(raw.get("outcome", "ok")),
"stepId": str(raw.get("stepId", "")),
"elapsedMs": elapsed_ms,
})
return SceneNode(
id="", x=x, y=y,
title=f"Build: {goal[:40]}" if goal else "Build",
Expand All @@ -721,6 +740,7 @@ def _restore_plan_payload(payload: dict[str, Any]) -> SceneNode:
state=PlanState(
plan_goal=goal,
plan_steps=steps,
builder_activity=activity,
builder_status=status,
builder_mode=mode if mode in ("copilot", "autopilot") else "copilot",
builder_run_id=str(payload.get("builder_run_id", "")),
Expand Down
6 changes: 6 additions & 0 deletions backend/session_save.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,12 @@ def _serialize_plan_node(node: SceneNode) -> dict[str, Any]:
"node_type": "plan",
"goal": node.state.plan_goal,
"steps": [dict(s) for s in node.state.plan_steps],
# stage 8.7: the run's activity log - persisted like every other
# PlanState field so it survives restart alongside the resume
# point, and (unlike the LIVE-run fields below) it describes past
# calls rather than an in-flight RunHandle, so nothing here needs
# load-time normalization.
"activity": [dict(a) for a in node.state.builder_activity],
"builder_status": node.state.builder_status,
"builder_mode": node.state.builder_mode,
"builder_run_id": node.state.builder_run_id,
Expand Down
Loading
Loading