Skip to content

Harden Living UI workflow engine and fix build-loop feedback bugs - #388

Open
ahmad-ajmal wants to merge 1 commit into
improvement/living-ui-V2from
improvement/living-ui-V2-test
Open

Harden Living UI workflow engine and fix build-loop feedback bugs#388
ahmad-ajmal wants to merge 1 commit into
improvement/living-ui-V2from
improvement/living-ui-V2-test

Conversation

@ahmad-ajmal

Copy link
Copy Markdown
Collaborator
  • Generic Workflow engine: pure check-report parser, phase property, JSONL run journal, engine-owned round counting (recorders can no longer inflate the budget), generic spawn-outcome tap
  • Split LivingUIManager into Ownership/Ports/Tunnels mixins
  • Ownership funnel: ghost guard keeps registry-named owners (any status), so parked tasks survive restarts and replies route to them
  • Feed pipeline errors verbatim into the next work round; fixlog captures failure blocks only, trims at attempt boundaries
  • PocketBase: accept legacy options.{values} field format, name the failing field in import-rejection errors

Workflows & the Agentic Loop

How a task turn flows through the shared loop, where deterministic
workflows plug in, and what changes when a task has no workflow at all.

The layering

agent_core/core/registry/          the SEAMS (domain-blind plug points)
  task_workflows.py                  workflow_id string → workflow object
  extensions.py                      behavioral hooks (boot_restore, task_end_gate, …)

app/agentic/                       HOW agents run (one loop for everything)
  loop.py        AgentLoop          the turn skeleton (Template Method)
  engine.py      decide()           the decide phase: session-delta, LLM call, parse, retry
  steps.py                          the step CONTRACT: {kind: actions|llm|wait|end}
  parsing.py                        one decision-JSON parser for every loop
  task_driver.py TaskTurn           the task driver's policy (subclass of AgentLoop)

app/workflows/                     WHAT they do (deterministic programs)
  workflow.py    Workflow   the produce-and-verify engine (stage chain + WorkState)
  living_ui/workflow.py             LivingUIWorkflow(Workflow) — the one live domain
  living_ui/steps.py                BuildState, fix ledger, platform recorder entry points
  */subagents/                      the specialists (coding_agent, walk_verify, research_agent)

app/living_ui/                     the PRODUCT (manager, PB runtime, event taps)

app/subagent/runner.py (SubAgentRunner) is the second AgentLoop
subclass — the blocking loop that runs a spawned sub-agent from start to
sub_task_end inside one action. Same skeleton, different execution
policy (it executes actions itself; TaskTurn returns decisions for
agent_base to execute between limits/persistence/triggers).

One task turn, hop by hop

  1. A trigger fires → agent_base._select_action_in_task
    ActionRouter.select_action_in_task constructs a TaskTurn and calls
    run_turn(task).

  2. TaskTurn.__init__ resolves the task's workflow:
    resolve_task_workflow(task) maps the persisted Task.workflow_id
    back to the registered workflow object (or None).

  3. AgentLoop.run_turn consults the step program:
    TaskTurn.step_program returns workflow.step — this single line
    is the whole coupling between the loop and Workflow.

  4. Workflow.stepcompute: load durable WorkState from disk,
    bundle it into a StepContext, and walk the stage chain — first stage
    with an opinion wins:

    wait_for_user → answer_user → bootstrap → budget_gate → work_round → check → finish

  5. The returned step's kind routes the turn back in the loop:

    kind What happens LLM call?
    actions on_code_stepstep_to_decisions → validated payloads → agent_base executes no
    llm the step's prompt rides the turn as a directive (append_step_directive — delivered even on delta turns), allowed_actions bounds the candidates one, bounded
    wait no-op decision; task idles until the next trigger no
    end becomes a task_end decision (still subject to the task_end_gate hook) no
    None plain LLM turn — the model decides freely yes
  6. Outcomes come back through taps, not stages. The stages never
    observe results; hooks record them into WorkState, and the next
    turn's step() reads the new state:

    Event Tap Records
    spawn_subagent completes construction_events._record_specialist_spawn record_spawn / record_walk_outcome (verdict, fixlog)
    living_ui_validate finishes manager.launch_and_verify wrapper record_validate_outcome (staged)
    user reply routed to the task registrations user_message_routed hook record_user_lead

The same skeleton, two behaviors

Every task — workflow or not — runs the identical turn skeleton. There is
exactly ONE fork point: what step_program returns.

trigger fires
  └─ agent_base → router.select_action_in_task → TaskTurn.run_turn(task)
       └─ step = consult( TaskTurn.step_program(task) )
                            │
          ┌─────────────────┴──────────────────┐
          │ workflow task                      │ plain task
          │ workflow.step → stage chain        │ step_program is None
          │ CODE decides this turn             │ LLM decides this turn
          └─────────────────┬──────────────────┘
                            ▼
          decisions returned → agent_base executes them   (identical again)

Everything else that differs is a consequence of the task carrying a
workflow_id, decided once at task creation:

Hop Workflow task (Flow A) Plain task (Flow B)
Task creation create_task(..., workflow_id="living_ui_creation") create_task(..., workflow_id=None)
System prompt workflow.system_prompt seeded into the session caches general agent prompt (identity/policies)
Memory / skills opted out (inject_memory / include_skills flags) injected as usual
Per-turn context + workflow.task_state() directive block generic task state
Who decides a turn code (stage chain); LLM only inside "llm" talk steps LLM, every turn
Action execution agent_base executes the returned decisions identical
Memory between turns durable WorkState on disk, written by taps the event stream
User msg mid-task wait_for_user/answer_user stages → ONE bounded LLM reply LLM sees it in events, reacts freely
Retry discipline round budget → budget-gate ask-user-and-wait, fix ledger none — LLM's judgment
Completion "end" step; task_end_gate refuses unvalidated completes LLM calls task_end; no gate listener claims it

The two traces below are concrete runs of the left and right columns.

Flow A — "Build me a habit tracker" (workflow-driven)

user msg → conversation mode select_action → LLM picks living_ui_scaffold
  → manager.create_project (template copy)
  → create_task(workflow_id="living_ui_creation") → trigger emitted

TURN 1   trigger → run_turn → workflow.step → stage_bootstrap
         → {"actions": spawn coding_agent(brief=requirements)}
         → agent_base executes; coding_agent runs its own AgentLoop
           (sub_task_end refused until build_passes + browser_verified pass)
         → TAP: record_spawn → fixlog

TURN 2   step: built, not staged → stage_work_round
         → {"actions": living_ui_validate} → launch pipeline
           (PB serve → schema import → typegen → build:debug → health)
         → TAP: record_validate_outcome → state.staged = True

TURN 3   step: staged, not verified → stage_check
         → {"actions": spawn walk_verify} → "VERDICT: FAIL — export dead"
         → TAP: record_walk_outcome → state.check_failures=[...]

TURN 4   step: failures present → stage_work_round (round 2)
         → {"actions": spawn coding_agent(failures + fixlog "already tried")}
         ⋯ user msg mid-build → stage_answer_user fires FIRST:
           {"kind":"llm", answer_user_prompt, allowed=[send_message]}
           → one bounded reply; TAP: record_user_lead

TURN N   walk passes → TAP: state.verified = True
         → on_verified stamps project.validation_passed_at

TURN N+1 stage_finish → {"actions": living_ui_notify_ready}
TURN N+2 stage_finish → {"kind":"llm", presentation_prompt}
         → present + task_end in one decision
         → task_end_gate hook: is_validated() true → complete

Budget exhausted instead? stage_budget_gate → one honest
{"kind":"llm"} report with wait_for_user_reply=true (including the
fix ledger's "already tried" block) → task parks; the reply is recorded
as a lead and the loop resumes.

Flow B — "Summarize my invoice emails" (no workflow)

Same trigger loop and executors as Flow A — only the right-hand column of
the table above applies.

user msg → conversation mode → LLM picks task_start
  → create_task(mode="complex", workflow_id=None)   ← the fork, decided here
  → trigger emitted

TURN 1   run_turn → step_program is None → consult returns None
         → llm_turn: full candidates + task_state + events
         → LLM decides: search_gmail("invoice") → agent_base executes
         → result lands in the EVENT STREAM (no WorkState, no taps)

TURN 2   delta turn: only NEW events reach the cached session
         → LLM decides: get_message(...) ×3

TURN 3   LLM decides: send_message(summary) + task_end(complete)
         → task_end_gate: no listener claims this task → allowed

Same trigger loop, same run_turn, same executors — with no
workflow_id the step program is None every turn, the LLM makes every
decision from the event stream, and "memory" is the event stream itself
rather than WorkState + taps.

Flow C — a second deterministic domain (hypothetical)

living_ui_creation is the only registered workflow today, but the
engine is domain-generic: any produce-and-verify job plugs in by
subclassing and answering a handful of questions. Example — a
"researched report" workflow (worker = the already-registered
research_agent; checker = a hypothetical fact_check sub-agent):

class ReportWorkflow(Workflow):
    name = "report_writing"              # → Task.workflow_id
    worker_agent = "research_agent"       # who does a work round
    checker_agent = "fact_check"          # who independently verifies
    state_class = WorkState               # generic state is enough
    round_budget = 3

    def resolve_subject(self, task):      # which job does this task own?
        return report_dir_for(task)
    def state_dir(self, subject):         # where WorkState persists
        return subject
    def is_bootstrapped(self, ctx):       # has round 1 produced anything?
        return (ctx.state_dir / "draft.md").exists()
    def work_query(self, ctx):            # the worker's instruction —
        return f"Research and write draft.md on {ctx.subject.topic}. " \
               f"Address every gap: {ctx.state.check_failures}"
    def check_query(self, ctx):           # the checker's instruction
        return "Verify every claim in draft.md against sources. " \
               "End with VERDICT: PASS|FAIL and a per-claim list."

register_workflow(ReportWorkflow())

Plus one outcome tap (the domain's equivalent of
_record_specialist_spawn) routing fact_check results into
record_check_outcome. Then the identical machinery runs it:

user msg → task_start(workflow_id="report_writing") → trigger

TURN 1   step → stage_bootstrap: no draft.md yet
         → {"actions": spawn research_agent(work_query)}
         → agent executes, writes draft.md
         → TAP: record_work_outcome → state.staged = True

TURN 2   step: staged, not verified → stage_check
         → {"actions": spawn fact_check(check_query)}
         → "VERDICT: FAIL — claim 3 unsourced, stat in §2 outdated"
         → TAP: record_check_outcome → check_failures=[...]

TURN 3   step: failures present → stage_work_round (round 2)
         → {"actions": spawn research_agent(work_query now embeds
            the two failed claims)} → revised draft
         ⋯ user asks "how's it going?" → stage_answer_user:
           {"kind":"llm", answer_user_prompt} → one bounded reply

TURN N   fact_check passes → state.verified = True
TURN N+1 stage_finish → {"kind":"llm", presentation_prompt}
         → deliver the report + task_end in one decision

Nothing in workflow.py changes; the domain contributes only
subject resolution, the two queries, and its outcome tap. All the loop
guarantees (round budget → budget-gate ask, durable state across
restarts, independent verification, task_end gating) come for free.

- Generic Workflow engine: pure check-report parser, phase property,
  JSONL run journal, engine-owned round counting (recorders can no
  longer inflate the budget), generic spawn-outcome tap
- Split LivingUIManager into Ownership/Ports/Tunnels mixins
- Ownership funnel: ghost guard keeps registry-named owners (any
  status), so parked tasks survive restarts and replies route to them
- Feed pipeline errors verbatim into the next work round; fixlog
  captures failure blocks only, trims at attempt boundaries
- PocketBase: accept legacy options.{values} field format, name the
  failing field in import-rejection errors
@ahmad-ajmal
ahmad-ajmal force-pushed the improvement/living-ui-V2-test branch from 4ffc9d0 to 62765c7 Compare July 17, 2026 11:02
@ahmad-ajmal ahmad-ajmal self-assigned this Jul 17, 2026
@ahmad-ajmal
ahmad-ajmal requested a review from zfoong July 17, 2026 11:02
@zfoong

zfoong commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

@ahmad-ajmal Can we close this PR?

ahmad-ajmal added a commit that referenced this pull request Aug 4, 2026
* Living UI V2: replace FastAPI/Vite system with PocketBase + vendored-kit platform

- New standalone living-ui-v2/ workspace: versioned React kit (realtime PB
  hooks, theme packs, console relay), project blueprint, and `lui` CLI
  (create/validate/dev/kit-sync/pb/ops/run/data/verify/probe)
- Validation gate: types, build, migrations-on-fresh-db, ops manifest,
  ownership hashes — with source-annotated errors and a same-error breaker
- walk_verify sub-agent: drives the running app in a real browser
  (playwright MCP) and blocks launch on observed defects (PR #388 contract)
- Manager/actions rewritten for single-process PB apps; V1 template,
  importer, and sidecar removed; ZIP import is deterministic code
- Creation wizard (layout/theme/reference files), option-chip QnA,
  session handoff, spec suite under living-ui-v2/spec/

* Add form and QnA workflow, some UI update

* Theme fix

* Living UI: shadcn-conventional kit APIs + approved npm dependency gate

* living UI building visualizer

* living UI visual update during creation

* separate walk verify + headless browser

* living UI import

* Add more ShadCN compatible UI component

* allow installing any npm

* auth fix

* fix cli issue

* Fix: Hide terminal pop ups

* Fix: Revert dev branch for living ui repo URL

* Fix: Chat panel shows after installing Living UI for the first time

* Fix: Include sessionId in both places that broadcast living_ui_ready

* Fix: Make ensure_project_session() checks non-fatal

* Fix: Assign env vars for callLLM() for Living UIs

* Revert changes Living UI marketplace links

* Error Catalogue revamp. �gent_core/core/errors.py provides new shared error formatting and cataloguing. Add immediate aborting of AUTH/CREDIT/QUOTA/MODEL/BLOCKED/BAD_REQUEST errors instead of silent retries.

* Fix: Ensure no consecutive errors, revert system error changes

* Add A2APP — make agent writes to Living UIs verifiable

An agent asked to "add a todo for tomorrow" wrote due_date: "tomorrow".
PocketBase returned 200, stored an empty string, and the agent told the
user it was scheduled. It had to guess what the app contained, the write
silently failed, and nothing stopped it claiming otherwise.

All three are now handled in the app, so any agent benefits — verified by
driving an app with curl alone, after deleting .superuser.

In the app (new pb_hooks, adapter 1.6.0):
- describe: entities, protocol types, conventions, from the live schema
- identity: PocketBase answers 200 for unknown paths, so an "a2app"
  marker is the only reliable probe
- write guard as router middleware, not a record hook — PocketBase
  coerces first, after which "tomorrow" and "" are indistinguishable
- read-back backstop; errors carry a code and list every violation
- origin guard, agent token, ops auth, rate limits, idempotency

In the CLI:
- reads describe, not PocketBase's superuser-only admin endpoint, so it
  holds no privilege an outside agent lacks
- resolves dates and labels client-side, where a clock and Intl exist
- adapter-sync ships hooks without re-vendoring the kit
- a valueless --flag now errors instead of becoming `true`
- gate rejects e.app inside runInTransaction, which deadlocks the process
  while /api/health still returns 200

In CraftBot:
- the system reports what changed, from the stored record; a false claim
  is withheld and handed back rather than corrected in front of the user
- the data model is inlined into the prompt — three attempts became one
- skills load per run, so operating an app no longer carries the build
  recipe that rebuilt a live app after one row insert
- integration bridge: capability gate, destination allowlist, no redirects

adapter-sync runs at create, install, import and launch — launch is the
only path reaching an app a user already had.

Adds spec/OVERVIEW.md and scripts/a2app-selfcheck.sh (21 checks).

* lint fixes

* Split error messages into two presentation tiers based level of important + Fix Chat persistency

* Fix: Testing and edge case fixes (blocked content error, NoneType crashes)

* Improvement: Sync test between ErrorCategory and ERROR_CATEGORY_STYLE

* Port error catalogue to Provider interfaces (embedding interface, image gen, video gen, factory + plan migration to actions, browser_adapter, CLI commands

* Add the Factory — deterministic build orchestration for weak models

Weak models write code well but manage themselves badly: 26 logged builds,
and every failure was self-management — churning on one dead end, fabricating
causes where evidence was blank, shrinking "email me" into "logs it",
announcing success after failed verification, or quitting unnoticed.
The model keeps the hands; this takes away the clipboard.

- app/factory: stdlib-pure Machine (persisted arc, 3×/12 retry caps,
  escalation, redispatch-on-surrender, honest stuck reports), defect cards
  (cause = quoted evidence or "unknown" — theories unrepresentable), build
  graph as a pure transition (only a parsed PASS reaches done), distiller,
  cookbooks (one generic callAction pattern + dry-run param discovery, email
  as the proven worked example), CraftBot host adapter; layering lint +
  3 test suites incl. replays of two real incidents.
- Fix missions are fresh runs carrying cards; ready/stuck messages are
  machine-composed — agent-authored status is retired.
- Evidence everywhere the platform was silent: gate stderr, response bodies,
  refused-request URLs, boot-log excerpts, handler exceptions, migrate-hang
  kills, pre-boot migration-rename check, CLI error.cause unwrapping.
- Integrations: bridge action endpoint runs CraftBot's own implementations
  (send_gmail, semantic params), grants derived from code by the gate,
  irreversible-confirm + dry-run; omitted `to` = account owner — apps never
  hold identity. Wizard offers marketplace matches; capability map injected.
- Verifier: full spec coverage required, value plausibility, fail-closed
  verdict parsing.

* Removed false claim gate

---------

Co-authored-by: CraftBot <craftbot@craftbot.dev>
Co-authored-by: Tobias Garcia <iguana3000tg@gmail.com>
Co-authored-by: イツミネ <tham_yikfoong@outlook.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants