From 62765c7c16b8d9443784c81f3a3323a43ddfec6d Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Fri, 17 Jul 2026 15:53:04 +0500 Subject: [PATCH] Harden Living UI workflow engine and fix build-loop feedback bugs - 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 --- .gitignore | 5 +- agent_core/core/impl/action/router.py | 734 +++--- agent_core/core/impl/context/engine.py | 37 +- agent_core/core/impl/llm/interface.py | 44 +- agent_core/core/impl/mcp/client.py | 70 +- agent_core/core/impl/task/manager.py | 32 +- agent_core/core/prompts/__init__.py | 6 +- agent_core/core/prompts/application.py | 272 +- agent_core/core/registry/extensions.py | 89 + agent_core/core/registry/task_workflows.py | 89 + app/agent_base.py | 177 +- app/agentic/__init__.py | 36 + app/agentic/_log.py | 16 + app/agentic/engine.py | 219 ++ app/agentic/loop.py | 106 + app/agentic/parsing.py | 95 + app/agentic/steps.py | 154 ++ app/agentic/task_driver.py | 179 ++ app/data/action/living_ui_actions.py | 224 +- app/data/action/spawn_subagent.py | 19 +- app/data/action/stream_edit.py | 12 - app/data/action/sub_task_end.py | 29 +- app/data/action/sub_task_todos.py | 107 + app/data/action/task_end.py | 14 + app/data/action/verify_build.py | 75 + app/data/action/write_file.py | 11 - app/data/agent_file_system_template/AGENT.md | 2 +- .../GLOBAL_LIVING_UI.md | 2 +- .../living_ui_modules/auth/AuthService.ts | 187 -- app/data/living_ui_modules/auth/README.md | 321 +-- app/data/living_ui_modules/auth/auth_types.ts | 48 - .../auth/backend/auth_middleware.py | 125 - .../auth/backend/auth_models.py | 164 -- .../auth/backend/auth_routes.py | 344 --- .../auth/backend/auth_service.py | 53 - .../auth/backend/tests/test_auth.py | 247 -- .../auth/frontend/AuthLayout.tsx | 102 - .../auth/frontend/AuthProvider.tsx | 143 +- .../auth/frontend/InviteModal.tsx | 210 +- .../auth/frontend/LoginPage.tsx | 100 +- .../auth/frontend/MemberList.tsx | 161 +- .../auth/frontend/ProfilePage.tsx | 199 +- .../auth/frontend/RegisterPage.tsx | 129 +- .../auth/frontend/UserMenu.tsx | 131 +- .../auth/pb_hooks/auth.pb.js | 88 + .../living_ui_modules/auth/requirements.txt | 2 - .../living_ui_modules/auth/schema.auth.json | 80 + app/data/living_ui_sidecar/proxy.py | 2 +- app/data/living_ui_template/.env.example | 11 +- app/data/living_ui_template/LIVING_UI.md | 16 +- .../living_ui_template/backend/database.py | 208 -- app/data/living_ui_template/backend/engine.py | 593 ----- .../living_ui_template/backend/export_data.py | 51 - .../backend/files_routes.py | 165 -- .../backend/health_checker.py | 156 -- app/data/living_ui_template/backend/logger.py | 76 - app/data/living_ui_template/backend/main.py | 176 -- app/data/living_ui_template/backend/models.py | 24 - .../backend/requirements.txt | 12 - app/data/living_ui_template/backend/routes.py | 47 - .../backend/services/integration_client.py | 181 -- .../backend/services/secrets.py | 60 - .../backend/system_models.py | 137 - .../backend/system_routes.py | 259 -- .../living_ui_template/backend/test_runner.py | 1184 --------- .../backend/tests/__init__.py | 0 .../backend/tests/conftest.py | 69 - .../backend/tests/test_example.py | 117 - app/data/living_ui_template/components.json | 20 + .../living_ui_template/config/manifest.json | 19 +- .../living_ui_template/config/operations.json | 52 +- .../living_ui_template/config/schema.json | 100 +- app/data/living_ui_template/frontend/App.tsx | 28 +- .../frontend/AppController.ts | 195 -- .../frontend/agent/devBuildMode.ts | 118 +- .../frontend/agent/hooks.ts | 65 - .../living_ui_template/frontend/api.gen.ts | 18 + .../frontend/components/MainView.tsx | 135 +- .../frontend/components/regions/README.md | 16 + .../frontend/components/ui/accordion.tsx | 56 + .../frontend/components/ui/alert-dialog.tsx | 139 + .../frontend/components/ui/alert.tsx | 59 + .../frontend/components/ui/aspect-ratio.tsx | 5 + .../frontend/components/ui/avatar.tsx | 132 +- .../frontend/components/ui/badge.tsx | 36 + .../frontend/components/ui/breadcrumb.tsx | 115 + .../frontend/components/ui/button.tsx | 56 + .../frontend/components/ui/calendar.tsx | 213 ++ .../frontend/components/ui/card.tsx | 79 + .../frontend/components/ui/carousel.tsx | 260 ++ .../frontend/components/ui/chart.tsx | 367 +++ .../frontend/components/ui/charts.tsx | 126 - .../frontend/components/ui/checkbox.tsx | 28 + .../frontend/components/ui/collapsible.tsx | 11 + .../frontend/components/ui/command.tsx | 153 ++ .../frontend/components/ui/confirm.tsx | 91 - .../frontend/components/ui/context-menu.tsx | 198 ++ .../frontend/components/ui/date-picker.tsx | 66 + .../frontend/components/ui/devtour.ts | 59 - .../frontend/components/ui/dialog.tsx | 122 + .../frontend/components/ui/dnd.tsx | 98 - .../frontend/components/ui/drawer.tsx | 267 +- .../frontend/components/ui/dropdown-menu.tsx | 200 ++ .../frontend/components/ui/entity.tsx | 482 ---- .../frontend/components/ui/form.tsx | 176 ++ .../frontend/components/ui/forms.tsx | 176 -- .../frontend/components/ui/hooks.ts | 50 - .../frontend/components/ui/hover-card.tsx | 27 + .../frontend/components/ui/index.tsx | 1621 ------------ .../frontend/components/ui/input-otp.tsx | 69 + .../frontend/components/ui/input.tsx | 22 + .../frontend/components/ui/label.tsx | 26 + .../frontend/components/ui/layout.tsx | 688 ----- .../frontend/components/ui/menu.tsx | 137 - .../frontend/components/ui/menubar.tsx | 254 ++ .../components/ui/navigation-menu.tsx | 128 + .../frontend/components/ui/pagination.tsx | 117 + .../frontend/components/ui/popover.tsx | 29 + .../frontend/components/ui/progress.tsx | 28 + .../frontend/components/ui/radio-group.tsx | 42 + .../frontend/components/ui/resizable.tsx | 45 + .../frontend/components/ui/scroll-area.tsx | 46 + .../frontend/components/ui/segmented.tsx | 71 - .../frontend/components/ui/select.tsx | 160 ++ .../frontend/components/ui/separator.tsx | 29 + .../frontend/components/ui/sheet.tsx | 140 + .../frontend/components/ui/skeleton.tsx | 15 + .../frontend/components/ui/slider.tsx | 26 + .../frontend/components/ui/sonner.tsx | 63 + .../frontend/components/ui/specimen.tsx | 54 - .../frontend/components/ui/switch.tsx | 27 + .../frontend/components/ui/table.tsx | 117 + .../frontend/components/ui/tabs.tsx | 53 + .../frontend/components/ui/textarea.tsx | 22 + .../frontend/components/ui/toast.tsx | 134 - .../frontend/components/ui/toggle-group.tsx | 61 + .../frontend/components/ui/toggle.tsx | 43 + .../frontend/components/ui/tooltip.tsx | 80 +- .../frontend/components/ui/upload.tsx | 155 -- .../living_ui_template/frontend/lib/pb.ts | 15 + .../{components/ui/theme.tsx => lib/theme.ts} | 0 .../living_ui_template/frontend/lib/utils.ts | 6 + app/data/living_ui_template/frontend/main.tsx | 9 - .../living_ui_template/frontend/schema.gen.ts | 11 - .../frontend/services/ApiService.ts | 150 +- .../frontend/services/ConsoleCapture.ts | 235 -- .../frontend/services/StatePersistence.ts | 175 -- .../frontend/services/UICapture.ts | 326 --- .../frontend/services/data.ts | 268 +- .../frontend/services/index.ts | 88 - .../frontend/styles/global.css | 75 +- .../frontend/styles/themes.css | 2 + .../living_ui_template/frontend/types.gen.ts | 10 +- app/data/living_ui_template/frontend/types.ts | 4 +- app/data/living_ui_template/index.html | 19 +- app/data/living_ui_template/package.json | 49 +- .../pb_hooks/_craftbot.pb.js | 96 + .../living_ui_template/pb_hooks/main.pb.js | 48 + app/data/living_ui_template/requirements.txt | 9 - .../living_ui_template/tailwind.config.js | 83 +- app/data/living_ui_template/tsconfig.json | 5 + app/data/living_ui_template/vite.config.ts | 27 +- app/internal_action_interface.py | 23 + app/living_ui/__init__.py | 9 + app/living_ui/broadcast.py | 8 +- app/living_ui/cli.py | 162 +- app/living_ui/construction_events.py | 237 +- app/living_ui/data_plane.py | 92 +- app/living_ui/manager.py | 2321 ++++++----------- app/living_ui/migration.py | 45 +- app/living_ui/operations.py | 10 +- app/living_ui/ops_analyzer.py | 62 +- app/living_ui/ownership.py | 325 +++ app/living_ui/pace_guard.py | 601 ----- app/living_ui/pocketbase_runtime.py | 407 +++ app/living_ui/ports.py | 240 ++ app/living_ui/registrations.py | 230 ++ app/living_ui/tunnels.py | 304 +++ app/living_ui/type_check.py | 6 +- app/living_ui/typegen.py | 249 +- app/living_ui/wizard.py | 8 +- app/subagent/__init__.py | 17 +- app/subagent/context_engine.py | 90 +- app/subagent/definitions/__init__.py | 25 - app/subagent/definitions/validation_agent.py | 174 -- app/subagent/exit_checks.py | 163 ++ app/subagent/manager.py | 2 +- app/subagent/registry.py | 45 +- app/subagent/runner.py | 393 +-- app/subagent/types.py | 25 +- app/ui_layer/adapters/browser_adapter.py | 84 +- .../src/contexts/WebSocketContext.tsx | 11 - .../src/pages/LivingUI/ConstructionView.tsx | 18 - app/ui_layer/controller/ui_controller.py | 3 +- app/usage/session_storage.py | 27 + app/workflows/README.md | 241 ++ app/workflows/__init__.py | 23 + app/workflows/base.py | 47 + app/workflows/common/__init__.py | 4 + app/workflows/common/subagents/__init__.py | 1 + .../common/subagents}/research_agent.py | 0 app/workflows/living_ui/__init__.py | 12 + app/workflows/living_ui/steps.py | 408 +++ app/workflows/living_ui/subagents/__init__.py | 10 + .../living_ui/subagents/coding_agent.py | 208 ++ .../living_ui/subagents/walk_verify.py | 132 + app/workflows/living_ui/verifiers.py | 69 + app/workflows/living_ui/workflow.py | 409 +++ app/workflows/living_ui/workspace.py | 201 ++ app/workflows/outcome_tap.py | 99 + app/workflows/workflow.py | 995 +++++++ skills/living-ui-creator/SKILL.md | 868 ++---- .../living-ui-creator/references/BACKEND.md | 337 +-- .../references/COMPONENTS.md | 651 +---- .../references/DESIGN_REVIEW.md | 116 +- .../living-ui-creator/references/EXAMPLES.md | 265 +- .../references/INTEGRATIONS.md | 173 +- skills/living-ui-creator/references/MVC-A.md | 477 ++-- .../references/OPERATIONS.md | 25 +- .../living-ui-creator/references/STANDARDS.md | 159 +- .../references/TROUBLESHOOTING.md | 106 +- skills/living-ui-creator/references/VERIFY.md | 297 +-- skills/living-ui-manager/SKILL.md | 3 +- skills/living-ui-modify/SKILL.md | 398 +-- tests/test_trigger_router_and_parking.py | 4 +- 225 files changed, 15319 insertions(+), 17893 deletions(-) create mode 100644 agent_core/core/registry/extensions.py create mode 100644 agent_core/core/registry/task_workflows.py create mode 100644 app/agentic/__init__.py create mode 100644 app/agentic/_log.py create mode 100644 app/agentic/engine.py create mode 100644 app/agentic/loop.py create mode 100644 app/agentic/parsing.py create mode 100644 app/agentic/steps.py create mode 100644 app/agentic/task_driver.py create mode 100644 app/data/action/sub_task_todos.py create mode 100644 app/data/action/verify_build.py delete mode 100644 app/data/living_ui_modules/auth/AuthService.ts delete mode 100644 app/data/living_ui_modules/auth/auth_types.ts delete mode 100644 app/data/living_ui_modules/auth/backend/auth_middleware.py delete mode 100644 app/data/living_ui_modules/auth/backend/auth_models.py delete mode 100644 app/data/living_ui_modules/auth/backend/auth_routes.py delete mode 100644 app/data/living_ui_modules/auth/backend/auth_service.py delete mode 100644 app/data/living_ui_modules/auth/backend/tests/test_auth.py delete mode 100644 app/data/living_ui_modules/auth/frontend/AuthLayout.tsx create mode 100644 app/data/living_ui_modules/auth/pb_hooks/auth.pb.js delete mode 100644 app/data/living_ui_modules/auth/requirements.txt create mode 100644 app/data/living_ui_modules/auth/schema.auth.json delete mode 100644 app/data/living_ui_template/backend/database.py delete mode 100644 app/data/living_ui_template/backend/engine.py delete mode 100644 app/data/living_ui_template/backend/export_data.py delete mode 100644 app/data/living_ui_template/backend/files_routes.py delete mode 100644 app/data/living_ui_template/backend/health_checker.py delete mode 100644 app/data/living_ui_template/backend/logger.py delete mode 100644 app/data/living_ui_template/backend/main.py delete mode 100644 app/data/living_ui_template/backend/models.py delete mode 100644 app/data/living_ui_template/backend/requirements.txt delete mode 100644 app/data/living_ui_template/backend/routes.py delete mode 100644 app/data/living_ui_template/backend/services/integration_client.py delete mode 100644 app/data/living_ui_template/backend/services/secrets.py delete mode 100644 app/data/living_ui_template/backend/system_models.py delete mode 100644 app/data/living_ui_template/backend/system_routes.py delete mode 100644 app/data/living_ui_template/backend/test_runner.py delete mode 100644 app/data/living_ui_template/backend/tests/__init__.py delete mode 100644 app/data/living_ui_template/backend/tests/conftest.py delete mode 100644 app/data/living_ui_template/backend/tests/test_example.py create mode 100644 app/data/living_ui_template/components.json delete mode 100644 app/data/living_ui_template/frontend/AppController.ts delete mode 100644 app/data/living_ui_template/frontend/agent/hooks.ts create mode 100644 app/data/living_ui_template/frontend/api.gen.ts create mode 100644 app/data/living_ui_template/frontend/components/regions/README.md create mode 100644 app/data/living_ui_template/frontend/components/ui/accordion.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/alert-dialog.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/alert.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/aspect-ratio.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/badge.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/breadcrumb.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/button.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/calendar.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/card.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/carousel.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/chart.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/charts.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/checkbox.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/collapsible.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/command.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/confirm.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/context-menu.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/date-picker.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/devtour.ts create mode 100644 app/data/living_ui_template/frontend/components/ui/dialog.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/dnd.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/dropdown-menu.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/entity.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/form.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/forms.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/hooks.ts create mode 100644 app/data/living_ui_template/frontend/components/ui/hover-card.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/index.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/input-otp.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/input.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/label.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/layout.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/menu.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/menubar.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/navigation-menu.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/pagination.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/popover.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/progress.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/radio-group.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/resizable.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/scroll-area.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/segmented.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/select.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/separator.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/sheet.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/skeleton.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/slider.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/sonner.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/specimen.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/switch.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/table.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/tabs.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/textarea.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/toast.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/toggle-group.tsx create mode 100644 app/data/living_ui_template/frontend/components/ui/toggle.tsx delete mode 100644 app/data/living_ui_template/frontend/components/ui/upload.tsx create mode 100644 app/data/living_ui_template/frontend/lib/pb.ts rename app/data/living_ui_template/frontend/{components/ui/theme.tsx => lib/theme.ts} (100%) create mode 100644 app/data/living_ui_template/frontend/lib/utils.ts delete mode 100644 app/data/living_ui_template/frontend/schema.gen.ts delete mode 100644 app/data/living_ui_template/frontend/services/ConsoleCapture.ts delete mode 100644 app/data/living_ui_template/frontend/services/StatePersistence.ts delete mode 100644 app/data/living_ui_template/frontend/services/UICapture.ts delete mode 100644 app/data/living_ui_template/frontend/services/index.ts create mode 100644 app/data/living_ui_template/pb_hooks/_craftbot.pb.js create mode 100644 app/data/living_ui_template/pb_hooks/main.pb.js delete mode 100644 app/data/living_ui_template/requirements.txt create mode 100644 app/living_ui/ownership.py delete mode 100644 app/living_ui/pace_guard.py create mode 100644 app/living_ui/pocketbase_runtime.py create mode 100644 app/living_ui/ports.py create mode 100644 app/living_ui/registrations.py create mode 100644 app/living_ui/tunnels.py delete mode 100644 app/subagent/definitions/__init__.py delete mode 100644 app/subagent/definitions/validation_agent.py create mode 100644 app/subagent/exit_checks.py create mode 100644 app/workflows/README.md create mode 100644 app/workflows/__init__.py create mode 100644 app/workflows/base.py create mode 100644 app/workflows/common/__init__.py create mode 100644 app/workflows/common/subagents/__init__.py rename app/{subagent/definitions => workflows/common/subagents}/research_agent.py (100%) create mode 100644 app/workflows/living_ui/__init__.py create mode 100644 app/workflows/living_ui/steps.py create mode 100644 app/workflows/living_ui/subagents/__init__.py create mode 100644 app/workflows/living_ui/subagents/coding_agent.py create mode 100644 app/workflows/living_ui/subagents/walk_verify.py create mode 100644 app/workflows/living_ui/verifiers.py create mode 100644 app/workflows/living_ui/workflow.py create mode 100644 app/workflows/living_ui/workspace.py create mode 100644 app/workflows/outcome_tap.py create mode 100644 app/workflows/workflow.py diff --git a/.gitignore b/.gitignore index 7419a084..a4358d96 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,7 @@ agent_file_system/TASK_HISTORY.md !build_template.py docs/LIVING_UI_DEVELOPER_GUIDE.md agent_file_system/ACTIONS.md -agent_bundle/ \ No newline at end of file +agent_bundle/ +.playwright-mcp/ +.ruff_cache/ +.pytest_cache/ \ No newline at end of file diff --git a/agent_core/core/impl/action/router.py b/agent_core/core/impl/action/router.py index 508ac776..a6c2f84e 100644 --- a/agent_core/core/impl/action/router.py +++ b/agent_core/core/impl/action/router.py @@ -9,7 +9,6 @@ from __future__ import annotations import json -import ast from typing import Optional, List, Dict, Any, Tuple from agent_core.core.state import get_state, get_session_or_none @@ -21,7 +20,6 @@ from agent_core.core.impl.llm.errors import LLMConsecutiveFailureError from agent_core.core.prompts import ( SELECT_ACTION_PROMPT, - SELECT_ACTION_IN_TASK_PROMPT, SELECT_ACTION_IN_GUI_PROMPT, SELECT_ACTION_IN_SIMPLE_TASK_PROMPT, GUI_ACTION_SPACE_PROMPT, @@ -113,6 +111,23 @@ async def select_action( logger.debug(f"[ACTION] Could not discover messaging actions: {e}") conversation_mode_actions = base_actions + # Domain-registered conversation actions (agent_core stays blind): + # each "conversation_actions" listener returns action names to offer + # in conversation mode (e.g. living_ui_develop — the direct entry + # for Living UI work, replacing task_start + skill guessing). + try: + from agent_core.core.registry.extensions import hook_listeners + + for listener in hook_listeners("conversation_actions"): + try: + for name in listener() or []: + if name not in conversation_mode_actions: + conversation_mode_actions.append(name) + except Exception: + pass + except Exception: + pass + action_candidates = [] for action in conversation_mode_actions: @@ -129,23 +144,7 @@ async def select_action( ) # Pull just-in-time guidance for any integrations the user named. - # No-ops to "" when nothing matches; never raises. See the helper - # in the host app — kept out of agent_core so the package stays - # integration-agnostic. - try: - from app.data.action.integrations._integration_essentials import ( - get_essentials_for_message, - ) - - # TODO: Is keyword based deterministic search good enough? - integration_essentials = get_essentials_for_message(query) - logger.info( - f"[ACTION] integration essentials: " - f"{len(integration_essentials)} chars injected" - ) - except Exception as e: - logger.debug(f"[ACTION] integration essentials lookup failed: {e}") - integration_essentials = "" + integration_essentials = self._get_integration_essentials(query, "conversation") # Build the instruction prompt for the LLM full_prompt = SELECT_ACTION_PROMPT.format( @@ -155,59 +154,11 @@ async def select_action( integration_essentials=integration_essentials, ) - max_format_retries = 3 - current_prompt = full_prompt - - for attempt in range(max_format_retries): - decision = await self._prompt_for_decision( - current_prompt, is_task=False, prompt_name="SELECT_ACTION" - ) - - # Parse parallel action decisions with format error detection - actions, format_error = self._parse_parallel_action_decisions(decision) - - if format_error: - # LLM returned wrong format - retry with feedback - logger.warning( - f"[FORMAT ERROR] Conversation mode attempt {attempt + 1}/{max_format_retries}: {format_error}" - ) - - if attempt < max_format_retries - 1: - current_prompt = self._augment_prompt_with_format_error( - full_prompt, attempt + 1, decision, format_error - ) - continue - else: - raise ValueError( - f"LLM output format error after {max_format_retries} attempts. " - f"Last error: {format_error}. Task aborted to prevent token waste." - ) - - if not actions: - # Empty action list (no format error) - return empty decision - return [ - { - "action_name": "", - "parameters": {}, - "reasoning": decision.get("reasoning", ""), - } - ] - - # Validate and filter parallel actions (GUI_mode=False for conversation) - validated_actions = self._validate_parallel_actions(actions, GUI_mode=False) - - if validated_actions: - action_names = [a.get("action_name") for a in validated_actions] - logger.info( - f"[PARALLEL] Conversation mode selected {len(validated_actions)} action(s): {action_names}" - ) - return validated_actions - - logger.warning( - f"No valid actions found during conversation selection attempt {attempt + 1}" - ) - - raise ValueError("Invalid selected action returned by LLM after retries.") + return await self._decide_actions_with_format_retry( + full_prompt, + mode_label="Conversation mode", + prompt_name="SELECT_ACTION", + ) @profile("action_router_select_action_in_task", OperationCategory.ACTION_ROUTING) async def select_action_in_task( @@ -216,6 +167,7 @@ async def select_action_in_task( action_type: Optional[str] = None, GUI_mode=False, session_id: Optional[str] = None, + user_message: Optional[str] = None, ) -> List[Dict[str, Any]]: """ When a task is running, this action selection will be used. @@ -226,6 +178,9 @@ async def select_action_in_task( action_type: Optional action type hint supplied to the LLM. GUI_mode: Whether the user is interacting through a GUI. session_id: Optional session ID for session-specific state lookup. + user_message: The routed user message for THIS turn (None for + continuation turns) — handed to a workflow step program so + code can decide whether to answer between steps. Returns: List[Dict[str, Any]]: List of decision payloads, each with ``action_name``, @@ -234,129 +189,23 @@ async def select_action_in_task( Raises: ValueError: If LLM returns invalid format 3 times consecutively. """ - action_candidates = [] - - # List of filtered actions - ignore_actions = ["ignore", "task_start"] - - # Get compiled action list from task's action sets - compiled_actions = self._get_current_task_compiled_actions( - session_id=session_id - ) - - # Use static compiled list - NO RAG SEARCH - action_candidates = self._build_candidates_from_compiled_list( - compiled_actions, GUI_mode, ignore_actions - ) - logger.info( - f"ActionRouter using compiled action list: {len(action_candidates)} actions" - ) - - # Build the instruction prompt for the LLM - task_state = self.context_engine.get_task_state(session_id=session_id) - event_stream_content = self.context_engine.get_event_stream( - session_id=session_id - ) - - # Pull integration essentials the same way conversation-mode does - # (see select_action). Without this, the task-mode LLM loses sight - # of integration-specific shortcuts (e.g. WhatsApp's `to: "user"` - # self-send) once the agent enters task mode and starts asking the - # user for info the integration could look up itself. - # Match against both the current step's query and the task state so - # the platform name from the original user request still triggers a - # match even after the per-step query is generic ("Perform the next - # best action..."). - try: - from app.data.action.integrations._integration_essentials import ( - get_essentials_for_message, - ) - - integration_essentials = get_essentials_for_message( - f"{query}\n{task_state}" - ) - logger.info( - f"[ACTION] task-mode integration essentials: " - f"{len(integration_essentials)} chars injected" - ) - except Exception as e: - logger.debug(f"[ACTION] task-mode essentials lookup failed: {e}") - integration_essentials = "" - - decision_prompt_name = "SELECT_ACTION_IN_TASK" - static_prompt = SELECT_ACTION_IN_TASK_PROMPT.format( - task_state=task_state, - event_stream="", # Empty for static prompt + # The turn itself lives on the shared AgentLoop skeleton + # (app/agentic/loop.py): step-program consult → code step | + # directive → LLM turn. TaskTurn (app/agentic/task_driver.py) is + # the task driver's policy — it builds prompts and validates + # decisions with THIS router's helpers and returns the decision + # payloads for agent_base to execute. Import deferred: agent_core + # must not import app.* at module load. + from app.agentic.task_driver import TaskTurn + + turn = TaskTurn( + self, query=query, - action_candidates=self._format_candidates(action_candidates), - integration_essentials=integration_essentials, + GUI_mode=GUI_mode, + session_id=session_id, + user_message=user_message, ) - full_prompt = SELECT_ACTION_IN_TASK_PROMPT.format( - task_state=task_state, - event_stream=event_stream_content, - query=query, - action_candidates=self._format_candidates(action_candidates), - integration_essentials=integration_essentials, - ) - - max_format_retries = 3 - current_prompt = full_prompt - - for attempt in range(max_format_retries): - decision = await self._prompt_for_decision( - current_prompt, - is_task=True, - static_prompt=static_prompt, - call_type=LLMCallType.ACTION_SELECTION, - session_id=session_id, - prompt_name=decision_prompt_name, - ) - - # Parse parallel action decisions with format error detection - actions, format_error = self._parse_parallel_action_decisions(decision) - - if format_error: - # LLM returned wrong format - retry with feedback - logger.warning( - f"[FORMAT ERROR] Task mode attempt {attempt + 1}/{max_format_retries}: {format_error}" - ) - - if attempt < max_format_retries - 1: - current_prompt = self._augment_prompt_with_format_error( - full_prompt, attempt + 1, decision, format_error - ) - continue - else: - raise ValueError( - f"LLM output format error after {max_format_retries} attempts. " - f"Last error: {format_error}. Task aborted to prevent token waste." - ) - - if not actions: - # Empty action list (no format error) - return empty decision for backward compatibility - return [ - { - "action_name": "", - "parameters": {}, - "reasoning": decision.get("reasoning", ""), - } - ] - - # Validate and filter parallel actions - validated_actions = self._validate_parallel_actions(actions, GUI_mode) - - if validated_actions: - action_names = [a.get("action_name") for a in validated_actions] - logger.info( - f"[PARALLEL] Selected {len(validated_actions)} action(s): {action_names}" - ) - return validated_actions - - logger.warning( - f"No valid actions found during selection attempt {attempt + 1}" - ) - - raise ValueError("Invalid selected action returned by LLM after retries.") + return await turn.run_turn(self._get_current_task(session_id)) @profile( "action_router_select_action_in_simple_task", OperationCategory.ACTION_ROUTING @@ -410,31 +259,11 @@ async def select_action_in_simple_task( # even after the agent has left conversation mode. Match against # the per-step query AND the task state so the original platform # keyword still triggers a hit. - try: - from app.data.action.integrations._integration_essentials import ( - get_essentials_for_message, - ) - - integration_essentials = get_essentials_for_message( - f"{query}\n{task_state}" - ) - logger.info( - f"[ACTION] simple-task integration essentials: " - f"{len(integration_essentials)} chars injected" - ) - except Exception as e: - logger.debug(f"[ACTION] simple-task essentials lookup failed: {e}") - integration_essentials = "" + integration_essentials = self._get_integration_essentials( + f"{query}\n{task_state}", "simple-task" + ) decision_prompt_name = "SELECT_ACTION_IN_SIMPLE_TASK" - static_prompt = SELECT_ACTION_IN_SIMPLE_TASK_PROMPT.format( - agent_state=self.context_engine.get_agent_state(session_id=session_id), - task_state=task_state, - event_stream="", # Empty for static prompt - query=query, - action_candidates=self._format_candidates(action_candidates), - integration_essentials=integration_essentials, - ) full_prompt = SELECT_ACTION_IN_SIMPLE_TASK_PROMPT.format( agent_state=self.context_engine.get_agent_state(session_id=session_id), task_state=task_state, @@ -444,67 +273,13 @@ async def select_action_in_simple_task( integration_essentials=integration_essentials, ) - max_format_retries = 3 - current_prompt = full_prompt - - for attempt in range(max_format_retries): - decision = await self._prompt_for_decision( - current_prompt, - is_task=True, - static_prompt=static_prompt, - call_type=LLMCallType.ACTION_SELECTION, - session_id=session_id, - prompt_name=decision_prompt_name, - ) - - # Parse parallel action decisions with format error detection - actions, format_error = self._parse_parallel_action_decisions(decision) - - if format_error: - # LLM returned wrong format - retry with feedback - logger.warning( - f"[FORMAT ERROR] Simple task attempt {attempt + 1}/{max_format_retries}: {format_error}" - ) - - if attempt < max_format_retries - 1: - # Augment prompt with format error feedback for retry - current_prompt = self._augment_prompt_with_format_error( - full_prompt, attempt + 1, decision, format_error - ) - continue - else: - # Max retries reached - abort - raise ValueError( - f"LLM output format error after {max_format_retries} attempts. " - f"Last error: {format_error}. Task aborted to prevent token waste." - ) - - if not actions: - # Empty action list (no format error) - return empty decision - return [ - { - "action_name": "", - "parameters": {}, - "reasoning": decision.get("reasoning", ""), - } - ] - - # Validate and filter parallel actions - validated_actions = self._validate_parallel_actions(actions, GUI_mode=False) - - if validated_actions: - action_names = [a.get("action_name") for a in validated_actions] - logger.info( - f"[PARALLEL] Simple task selected {len(validated_actions)} action(s): {action_names}" - ) - return validated_actions - - # Actions parsed but not valid (action not found, etc.) - logger.warning( - f"No valid actions found during simple task selection attempt {attempt + 1}" - ) - - raise ValueError("Invalid selected action returned by LLM after retries.") + return await self._decide_actions_with_format_retry( + full_prompt, + mode_label="Simple task", + prompt_name=decision_prompt_name, + is_task=True, + session_id=session_id, + ) @profile("action_router_select_action_in_GUI", OperationCategory.ACTION_ROUTING) async def select_action_in_GUI( @@ -545,12 +320,6 @@ async def select_action_in_GUI( session_id=session_id ) decision_prompt_name = "SELECT_ACTION_IN_GUI" - static_prompt = SELECT_ACTION_IN_GUI_PROMPT.format( - agent_state=self.context_engine.get_agent_state(session_id=session_id), - task_state=task_state, - event_stream="", # Empty for static prompt - gui_action_space=GUI_ACTION_SPACE_PROMPT, - ) full_prompt = SELECT_ACTION_IN_GUI_PROMPT.format( agent_state=self.context_engine.get_agent_state(session_id=session_id), task_state=task_state, @@ -565,7 +334,6 @@ async def select_action_in_GUI( decision = await self._prompt_for_decision( current_prompt, is_task=True, - static_prompt=static_prompt, call_type=LLMCallType.GUI_ACTION_SELECTION, session_id=session_id, prompt_name=decision_prompt_name, @@ -616,10 +384,10 @@ async def _prompt_for_decision( self, prompt: str, is_task: bool = False, - static_prompt: Optional[str] = None, call_type: str = LLMCallType.ACTION_SELECTION, session_id: Optional[str] = None, prompt_name: Optional[str] = None, + turn_directive: Optional[str] = None, ) -> Dict[str, Any]: """ Prompt the LLM for an action decision with session caching support. @@ -627,16 +395,17 @@ async def _prompt_for_decision( Args: prompt: The full prompt to send to the LLM. is_task: Whether this is a task-related call. - static_prompt: Optional static portion for caching. call_type: Type of LLM call for cache keying. session_id: Optional session ID for session-specific state lookup. prompt_name: Identity of the named prompt, tagged onto the captured LLM call for per-prompt profiling. + turn_directive: Optional instruction that must reach the model + THIS turn (a step program's "llm" prompt). Appended after + the delta on delta turns — the full prompt already carries + it via the query slot, but delta turns would otherwise send + only events and the directive would never be seen (the + Round-13 task_state delivery failure). """ - max_retries = 3 - last_error: Optional[Exception] = None - current_prompt = prompt - # Get current task_id for session cache (if running in a task) # Use session_id if provided, otherwise fall back to global state if session_id: @@ -650,105 +419,116 @@ async def _prompt_for_decision( else: current_task_id = "" - for attempt in range(max_retries): - # KV CACHING: System prompt is STATIC only (no dynamic content) - # agent_info is included for all modes to provide consistent agent context + # Sub-workflow tasks run with their own purpose-built system prompt + # instead of the general agent's (identity/soul/policies/file-system + # rules) — same task machinery, focused context. + # KV CACHING: the system prompt is STATIC only (no dynamic content); + # agent_info is included for consistent agent context. + workflow = ( + self._get_current_task_workflow(session_id or current_task_id) + if is_task + else None + ) + if workflow is not None: + system_prompt = workflow.system_prompt + else: system_prompt, _ = self.context_engine.make_prompt( user_flags={"query": False, "expected_output": False}, system_flags={"agent_info": True}, ) - raw_response = None + # Session registered (complex task): the whole decide phase — delta + # orchestration, LLM call, parse, retry-with-feedback (incl. provider + # errors) — is the shared TurnEngine (app/agentic/engine), the same + # one the sub-agent runner uses. It owns ALL retries on this path. + if ( + current_task_id + and is_task + and self.llm_interface.has_session_cache(current_task_id, call_type) + ): + return await self._decide_via_session_engine( + session_key=current_task_id, + call_type=call_type, + system_prompt=system_prompt, + first_prompt=prompt, + turn_directive=turn_directive, + prompt_name=prompt_name, + session_id=session_id, + ) - try: - # Use session cache if we're in a task context AND session is registered - if current_task_id and is_task: - has_session = self.llm_interface.has_session_cache( - current_task_id, call_type - ) + # No session registered (simple task / conversation mode): plain + # prefix-cached calls with this router's own retry loop. + return await self._decide_uncached(system_prompt, prompt, prompt_name) - if has_session: - # Session is registered (complex task) - use session caching - # CRITICAL: Use session-specific stream to prevent event leakage - from agent_core import get_event_stream_manager - - event_stream_manager = get_event_stream_manager() - # Use get_stream_by_id with session_id to get the correct task's stream - effective_session_id = session_id or current_task_id - stream = ( - event_stream_manager.get_stream_by_id(effective_session_id) - if event_stream_manager - else None - ) - has_synced_before = ( - stream.has_session_sync(call_type) if stream else False - ) + async def _decide_via_session_engine( + self, + *, + session_key: str, + call_type: str, + system_prompt: str, + first_prompt: str, + turn_directive: Optional[str], + prompt_name: Optional[str], + session_id: Optional[str], + ) -> Dict[str, Any]: + """One engine-backed decide over the provider session cache.""" + from agent_core import get_event_stream_manager + from app.agentic.engine import ( + DecideConfig, + append_step_directive, + decide, + ) - if has_synced_before: - # We've made calls before - send only delta events - # CRITICAL: Pass session_id to get delta from the correct stream - delta_events, has_delta = ( - self.context_engine.get_event_stream_delta( - call_type, session_id=effective_session_id - ) - ) - - if has_delta: - # Send only the new events - logger.info( - f"[SESSION CACHE] Sending delta events for {call_type}" - ) - raw_response = await self.llm_interface.generate_response_with_session_async( - task_id=current_task_id, - call_type=call_type, - user_prompt=delta_events, - system_prompt_for_new_session=system_prompt, - prompt_name=prompt_name, - ) - # Mark events as synced after successful call - self.context_engine.mark_event_stream_synced( - call_type, session_id=effective_session_id - ) - else: - # No new events - this could mean summarization happened - logger.info( - f"[SESSION CACHE] No delta events, resetting cache for {call_type}" - ) - self.llm_interface.end_session_cache( - current_task_id, call_type - ) - self.context_engine.reset_event_stream_sync( - call_type, session_id=effective_session_id - ) - # Fall through to first-call path - has_synced_before = False - - if not has_synced_before: - # First call with session - send full prompt to establish session - logger.info( - f"[SESSION CACHE] Creating new session for {call_type} (first call)" - ) - raw_response = await self.llm_interface.generate_response_with_session_async( - task_id=current_task_id, - call_type=call_type, - user_prompt=current_prompt, - system_prompt_for_new_session=system_prompt, - prompt_name=prompt_name, - ) - # Mark events as synced after successful session creation - self.context_engine.mark_event_stream_synced( - call_type, session_id=effective_session_id - ) - else: - # No session registered (simple task) - use prefix cache / regular response - raw_response = await self.llm_interface.generate_response_async( - system_prompt, current_prompt, prompt_name=prompt_name - ) - else: - # Not in task context - use regular response - raw_response = await self.llm_interface.generate_response_async( - system_prompt, current_prompt, prompt_name=prompt_name - ) + event_stream_manager = get_event_stream_manager() + effective_session_id = session_id or session_key + stream = ( + event_stream_manager.get_stream_by_id(effective_session_id) + if event_stream_manager + else None + ) + decision, _err = await decide( + self.llm_interface, + stream, + DecideConfig( + session_key=session_key, + call_type=call_type, + system_prompt=system_prompt, + first_prompt=first_prompt, + # Tasks send raw delta events as the turn prompt; a step + # directive (if any) rides AFTER the events so it's the + # last thing the model reads before deciding. + delta_prompt_builder=lambda delta: append_step_directive( + delta, turn_directive + ), + parse=self._parse_action_decision, + augment_retry=self._augment_prompt_with_feedback, + prompt_name=prompt_name, + on_exhaust="raise", + ), + ) + decision.setdefault("parameters", {}) + decision["parameters"] = self._ensure_parameters(decision.get("parameters")) + return decision + + async def _decide_uncached( + self, + system_prompt: str, + prompt: str, + prompt_name: Optional[str], + ) -> Dict[str, Any]: + """Plain (non-session) decide: prefix-cached calls, parse, and this + router's retry-with-feedback loop.""" + max_retries = 3 + last_error: Optional[Exception] = None + current_prompt = prompt + + for attempt in range(max_retries): + raw_response = None + + try: + raw_response = await self.llm_interface.generate_response_async( + system_prompt, current_prompt, prompt_name=prompt_name + ) # Validate response before parsing if not raw_response or ( @@ -823,60 +603,143 @@ async def _prompt_for_decision( raise last_error raise ValueError("Unable to parse LLM decision") - def _parse_action_decision( - self, raw: str - ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: - # Check for empty or None response from LLM - if not raw or (isinstance(raw, str) and not raw.strip()): - logger.error("LLM returned empty response") - return ( - None, - "LLM returned an empty response. This may indicate an API error or the model failed to generate output.", + def _get_integration_essentials(self, text: str, label: str) -> str: + """Just-in-time integration guidance matched against ``text``. + + No-ops to "" when nothing matches; never raises. The lookup lives + in the host app — kept out of agent_core so the package stays + integration-agnostic (deferred import).""" + try: + from app.data.action.integrations._integration_essentials import ( + get_essentials_for_message, ) - # Normalize Windows/encoding artifacts (BOM, CRLF, etc.) - # This handles Windows CRLF line endings and encoding issues - normalized = raw + # TODO: Is keyword based deterministic search good enough? + essentials = get_essentials_for_message(text) + logger.info( + f"[ACTION] {label} integration essentials: " + f"{len(essentials)} chars injected" + ) + return essentials + except Exception as e: + logger.debug(f"[ACTION] {label} essentials lookup failed: {e}") + return "" - # Remove BOM if present (Windows encoding artifact) - if normalized.startswith("\ufeff"): - normalized = normalized[1:] + async def _decide_actions_with_format_retry( + self, + full_prompt: str, + *, + mode_label: str, + prompt_name: str, + is_task: bool = False, + call_type: str = LLMCallType.ACTION_SELECTION, + session_id: Optional[str] = None, + turn_directive: Optional[str] = None, + GUI_mode: bool = False, + allowed_actions: Optional[List[str]] = None, + ) -> List[Dict[str, Any]]: + """The action-decision loop every batch selection mode shares: + decide → parse the batch contract → format-error retry (≤3 with + feedback) → optional step allow-list filter → validate. - # Normalize line endings to LF (convert CRLF to LF) - normalized = normalized.replace("\r\n", "\n") + Returns validated decision payloads, or the single empty decision + when the model (or the allow-list filter) selected nothing; raises + ``ValueError`` after exhausting retries.""" + max_format_retries = 3 + current_prompt = full_prompt - # Remove any remaining carriage returns - normalized = normalized.replace("\r", "") + for attempt in range(max_format_retries): + decision = await self._prompt_for_decision( + current_prompt, + is_task=is_task, + call_type=call_type, + session_id=session_id, + prompt_name=prompt_name, + turn_directive=turn_directive, + ) - # Strip all leading/trailing whitespace - normalized = normalized.strip() + actions, format_error = self._parse_parallel_action_decisions(decision) + if format_error: + logger.warning( + f"[FORMAT ERROR] {mode_label} attempt " + f"{attempt + 1}/{max_format_retries}: {format_error}" + ) + if attempt < max_format_retries - 1: + current_prompt = self._augment_prompt_with_format_error( + full_prompt, attempt + 1, decision, format_error + ) + continue + raise ValueError( + f"LLM output format error after {max_format_retries} " + f"attempts. Last error: {format_error}. Task aborted to " + "prevent token waste." + ) - if not normalized: - logger.error( - f"Response was empty after normalization. Original: {repr(raw)}" - ) - return ( - None, - "LLM response was empty or only contained whitespace after normalization.", - ) + if not actions: + return self._empty_decision(decision) + + # An "llm" step bounds the decision: anything outside its + # allow-list is dropped (the step said what this turn is FOR). + if allowed_actions: + allowed_set = set(allowed_actions) + outside = [ + a for a in actions if a.get("action_name") not in allowed_set + ] + if outside: + logger.warning( + f"[STEP] {session_id} dropped actions outside the " + f"step allow-list: " + f"{[a.get('action_name') for a in outside]}" + ) + actions = [ + a for a in actions if a.get("action_name") in allowed_set + ] + if not actions: + return self._empty_decision(decision) - try: - parsed = json.loads(normalized) - except json.JSONDecodeError as json_error: - try: - parsed = ast.literal_eval(normalized) - except Exception as eval_error: - logger.error(f"Unable to parse action decision: {repr(normalized)}") - return ( - None, - f"json error: {json_error}; literal_eval error: {eval_error}", + validated_actions = self._validate_parallel_actions(actions, GUI_mode) + if validated_actions: + logger.info( + f"[PARALLEL] {mode_label} selected " + f"{len(validated_actions)} action(s): " + f"{[a.get('action_name') for a in validated_actions]}" ) + return validated_actions - if not isinstance(parsed, dict): - logger.error(f"Parsed action decision is not a dict: {repr(normalized)}") - return None, "parsed value is not a dictionary" + logger.warning( + f"No valid actions found during {mode_label} selection " + f"attempt {attempt + 1}" + ) + + raise ValueError("Invalid selected action returned by LLM after retries.") + + @staticmethod + def _empty_decision(decision: Dict[str, Any]) -> List[Dict[str, Any]]: + """The no-op decision payload (model chose nothing this turn).""" + return [ + { + "action_name": "", + "parameters": {}, + "reasoning": decision.get("reasoning", ""), + } + ] + + def _parse_action_decision( + self, raw: str + ) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + """Raw LLM text \u2192 decision dict. + + Delegates to the shared parser (app/agentic/parsing) \u2014 one + implementation for every loop; this call site gains markdown-fence + stripping (previously a burned retry). The task-format action-list + extraction (format-error coaching) stays in this router because it + is specific to the task prompt contract.""" + from app.agentic.parsing import parse_decision_dict - return parsed, None + decision, err = parse_decision_dict(raw) + if decision is None: + logger.error(f"Unable to parse action decision: {err} | {repr(raw)[:300]}") + return decision, err def _augment_prompt_with_feedback( self, @@ -1326,6 +1189,29 @@ def _build_candidates_from_compiled_list( return candidates + def _get_current_task(self, session_id: Optional[str] = None): + """The current Task for ``session_id`` (session state first, global + state fallback), or None. Fail-open.""" + try: + session = get_session_or_none(session_id) + if session and session.current_task: + return session.current_task + return get_state().current_task + except Exception: + return None + + def _get_current_task_workflow(self, session_id: Optional[str] = None): + """The workflow for the current task, or None (= default + behavior). A workflow gives the task a purpose-built system prompt + and opts out of general-agent injections. Fail-open: any problem + resolving it means "no workflow".""" + try: + from agent_core.core.registry.task_workflows import resolve_task_workflow + + return resolve_task_workflow(self._get_current_task(session_id)) + except Exception: + return None + def _get_current_task_compiled_actions( self, session_id: Optional[str] = None ) -> List[str]: diff --git a/agent_core/core/impl/context/engine.py b/agent_core/core/impl/context/engine.py index a41a1c92..f9f02717 100644 --- a/agent_core/core/impl/context/engine.py +++ b/agent_core/core/impl/context/engine.py @@ -495,10 +495,39 @@ def get_task_state(self, session_id: Optional[str] = None) -> str: "Mode: Complex task - use todos in event stream to track progress", ] - skill_instructions = self.get_skill_instructions(session_id=session_id) - if skill_instructions: - lines.append("") - lines.append(skill_instructions) + # Sub-workflow tasks carry their own purpose-built system prompt; + # re-rendering full skill bodies here every turn is exactly the + # bloat they exist to avoid (skill files stay readable on disk). + include_skills = True + workflow = None + try: + from agent_core.core.registry.task_workflows import ( + resolve_task_workflow, + ) + + workflow = resolve_task_workflow(current_task) + if workflow is not None and not workflow.include_skills: + include_skills = False + except Exception: + include_skills = True + + if include_skills: + skill_instructions = self.get_skill_instructions(session_id=session_id) + if skill_instructions: + lines.append("") + lines.append(skill_instructions) + + # Workflow-computed per-turn state (e.g. the Living UI build + # directive: code decides the next step, the LLM executes it). + # Fail-open: a workflow hook failure must never break a turn. + if workflow is not None: + try: + provided = workflow.task_state(current_task) + if provided: + lines.append("") + lines.append(str(provided)) + except Exception as e: + logger.debug(f"[CONTEXT_ENGINE] workflow.task_state failed: {e}") lines.append("") return "\n".join(lines) diff --git a/agent_core/core/impl/llm/interface.py b/agent_core/core/impl/llm/interface.py index 945cb82a..398b5d0c 100644 --- a/agent_core/core/impl/llm/interface.py +++ b/agent_core/core/impl/llm/interface.py @@ -163,9 +163,18 @@ def __init__( self._log_to_db = log_to_db self._record_llm_call = record_llm_call - # Consecutive failure tracking to prevent infinite retry loops + # Consecutive failure tracking to prevent infinite retry loops. + # HALF-OPEN breaker: after the threshold, calls abort instantly only + # while the cooldown since the LAST failure is running; once it + # elapses, ONE probe call is allowed through — success resets the + # counter (provider recovered, e.g. credits topped up), failure + # re-latches for another cooldown. Without this the breaker + # hard-latched until app restart: no call could ever run, so the + # success-reset was unreachable. self._consecutive_failures = 0 self._max_consecutive_failures = 5 + self._last_failure_at = 0.0 + self._breaker_cooldown_s = 60.0 # Defer imports to avoid circular dependency from app.models.factory import ModelFactory @@ -505,12 +514,17 @@ def _generate_response_sync( # Check if we've hit the consecutive failure threshold if self._consecutive_failures >= self._max_consecutive_failures: - logger.critical( - f"[LLM ABORT] Consecutive failure threshold reached " - f"({self._consecutive_failures}/{self._max_consecutive_failures}). " - f"Aborting to prevent infinite retries." + if time.time() - self._last_failure_at < self._breaker_cooldown_s: + logger.critical( + f"[LLM ABORT] Consecutive failure threshold reached " + f"({self._consecutive_failures}/{self._max_consecutive_failures}). " + f"Aborting to prevent infinite retries." + ) + raise LLMConsecutiveFailureError(self._consecutive_failures) + logger.warning( + f"[LLM BREAKER] Cooldown elapsed after " + f"{self._consecutive_failures} failures — allowing a probe call" ) - raise LLMConsecutiveFailureError(self._consecutive_failures) if log_response: logger.info(f"[LLM SEND] system={system_prompt} | user={user_prompt}") @@ -564,6 +578,7 @@ def _generate_response_sync( logger.error(f"[LLM ERROR] {error_detail}") # Track consecutive failure self._consecutive_failures += 1 + self._last_failure_at = time.time() logger.warning( f"[LLM CONSECUTIVE FAILURE] Count: {self._consecutive_failures}/{self._max_consecutive_failures}" ) @@ -602,6 +617,7 @@ def _generate_response_sync( except Exception as e: # Track consecutive failure for any other exception self._consecutive_failures += 1 + self._last_failure_at = time.time() logger.warning( f"[LLM CONSECUTIVE FAILURE] Count: {self._consecutive_failures}/{self._max_consecutive_failures} | Error: {e}" ) @@ -916,6 +932,7 @@ def _finalize_session_response( ) logger.error(f"[LLM ERROR] {error_detail}") self._consecutive_failures += 1 + self._last_failure_at = time.time() logger.warning( f"[LLM CONSECUTIVE FAILURE] Count: " f"{self._consecutive_failures}/{self._max_consecutive_failures}" @@ -970,12 +987,17 @@ def _generate_response_with_session_sync( # session path previously had none, so a persistent provider error # (e.g. out-of-credits) retried forever instead of aborting. if self._consecutive_failures >= self._max_consecutive_failures: - logger.critical( - f"[LLM ABORT] Consecutive failure threshold reached " - f"({self._consecutive_failures}/{self._max_consecutive_failures}). " - f"Aborting to prevent infinite retries." + if time.time() - self._last_failure_at < self._breaker_cooldown_s: + logger.critical( + f"[LLM ABORT] Consecutive failure threshold reached " + f"({self._consecutive_failures}/{self._max_consecutive_failures}). " + f"Aborting to prevent infinite retries." + ) + raise LLMConsecutiveFailureError(self._consecutive_failures) + logger.warning( + f"[LLM BREAKER] Cooldown elapsed after " + f"{self._consecutive_failures} failures — allowing a probe call" ) - raise LLMConsecutiveFailureError(self._consecutive_failures) if log_response: logger.info( diff --git a/agent_core/core/impl/mcp/client.py b/agent_core/core/impl/mcp/client.py index ca660ecc..4814b2da 100644 --- a/agent_core/core/impl/mcp/client.py +++ b/agent_core/core/impl/mcp/client.py @@ -58,6 +58,11 @@ def __init__(self): self._servers: Dict[str, MCPServerConnection] = {} self._config: Optional[MCPConfig] = None self._event_loop: Optional[asyncio.AbstractEventLoop] = None + # Auto-revive bookkeeping: one lock per server so concurrent callers + # respawn it once, and a cooldown so a server that genuinely cannot + # start is not re-spawned on every single tool call. + self._revive_locks: Dict[str, asyncio.Lock] = {} + self._revive_failed_at: Dict[str, float] = {} self._initialized = True @property @@ -234,6 +239,61 @@ def get_server_tools(self, server_name: str) -> List[MCPTool]: return [] return self._servers[server_name].tools + # A stdio server whose child has exited is not a permanent condition — it + # is a dead subprocess we can spawn again. Without this, the first crash + # of an MCP server would kill its tools for the rest of the session: + # every later call would fail instantly with "connection lost". + _REVIVE_COOLDOWN_S = 30.0 + + async def _revive_server( + self, server_name: str, server: MCPServerConnection + ) -> bool: + """Respawn a server whose process has died. True if it is usable now. + + Serialized per server (concurrent callers revive it once) and rate + limited, so a server that cannot start costs one attempt per cooldown + rather than one per tool call. Never raises: a failed revive is + reported to the caller as an unavailable tool. + """ + lock = self._revive_locks.setdefault(server_name, asyncio.Lock()) + async with lock: + if server.is_connected: + return True # another caller already brought it back + + import time + + now = time.monotonic() + last_failure = self._revive_failed_at.get(server_name) + if ( + last_failure is not None + and now - last_failure < self._REVIVE_COOLDOWN_S + ): + return False + + logger.warning( + f"[MCP] Server '{server_name}' has died — restarting it " + f"(its tools were unavailable)" + ) + try: + revived = await server.reconnect() + except Exception as e: + logger.error(f"[MCP] Restart of '{server_name}' raised: {e}") + revived = False + + if revived: + self._revive_failed_at.pop(server_name, None) + logger.info( + f"[MCP] Server '{server_name}' restarted with " + f"{len(server.tools)} tools — its actions work again" + ) + else: + self._revive_failed_at[server_name] = now + logger.error( + f"[MCP] Server '{server_name}' could not be restarted; " + f"retrying in at most {self._REVIVE_COOLDOWN_S:.0f}s" + ) + return revived + async def call_tool( self, server_name: str, tool_name: str, arguments: Dict[str, Any] ) -> Dict[str, Any]: @@ -255,10 +315,16 @@ async def call_tool( } server = self._servers[server_name] - if not server.is_connected: + if not server.is_connected and not await self._revive_server( + server_name, server + ): return { "status": "error", - "message": f"MCP server '{server_name}' connection lost", + "message": ( + f"MCP server '{server_name}' connection lost and could not " + f"be restarted. Its tools are unavailable — use another way " + f"to do this, or continue without it." + ), } result = await server.call_tool(tool_name, arguments) diff --git a/agent_core/core/impl/task/manager.py b/agent_core/core/impl/task/manager.py index 4b1b8889..ab24c200 100644 --- a/agent_core/core/impl/task/manager.py +++ b/agent_core/core/impl/task/manager.py @@ -382,9 +382,14 @@ def create_task( # the LLM's expansion of the user message; for proactive / scheduled # tasks it's the trigger description. inject_memory_event no-ops if # nothing passes min_relevance, so noise is filtered automatically. - from agent_core.core.impl.memory.injector import inject_memory_event + # Sub-workflow tasks opt out: their focused prompt is the context. + from agent_core.core.registry.task_workflows import get_workflow - inject_memory_event(query=task_instruction, session_id=task_id) + task_workflow = get_workflow(workflow_id) + if task_workflow is None or task_workflow.inject_memory: + from agent_core.core.impl.memory.injector import inject_memory_event + + inject_memory_event(query=task_instruction, session_id=task_id) self._set_agent_property("current_task_id", task_id) @@ -400,12 +405,25 @@ def create_task( return task_id def _create_session_caches(self, task_id: str) -> None: - """Create session caches for a task.""" + """Create session caches for a task. Sub-workflow tasks seed their + purpose-built system prompt instead of the general agent's.""" try: - system_prompt, _ = self.context_engine.make_prompt( - user_flags={"query": False, "expected_output": False}, - system_flags={}, - ) + task = self.tasks.get(task_id) + try: + from agent_core.core.registry.task_workflows import ( + resolve_task_workflow, + ) + + workflow = resolve_task_workflow(task) + except Exception: + workflow = None + if workflow is not None: + system_prompt = workflow.system_prompt + else: + system_prompt, _ = self.context_engine.make_prompt( + user_flags={"query": False, "expected_output": False}, + system_flags={}, + ) for call_type in [ LLMCallType.REASONING, LLMCallType.ACTION_SELECTION, diff --git a/agent_core/core/prompts/__init__.py b/agent_core/core/prompts/__init__.py index 04ca7b5a..4e1e3f32 100644 --- a/agent_core/core/prompts/__init__.py +++ b/agent_core/core/prompts/__init__.py @@ -105,9 +105,9 @@ ACTION_SET_SELECTION_PROMPT, ) -# Sub-agent prompts now live alongside the sub-agent runtime, in -# ``app.subagent.definitions`` (per-type system prompts) and -# ``app.subagent.context_engine`` (shared output-format contract). +# Sub-agent prompts live with their workflows — per-type system prompts +# in ``app/workflows//subagents/`` — and the shared output-format +# contract in ``app.subagent.context_engine``. __all__ = [ # Registry diff --git a/agent_core/core/prompts/application.py b/agent_core/core/prompts/application.py index db49a46c..bac6e3da 100644 --- a/agent_core/core/prompts/application.py +++ b/agent_core/core/prompts/application.py @@ -5,10 +5,13 @@ Contains prompt templates for Living UI and other application features. """ -# NOTE: This instruction is a condensed mirror of the canonical workflow in -# skills/living-ui-creator/SKILL.md (which the task also loads via -# selected_skills). If you change the workflow, ports, or quality bar in -# either place, update the other — they drift independently otherwise. +# Per-project facts ONLY. Every stable rule (platform architecture, the +# build loop, gates, debug protocol, quality bar) lives in the +# `living_ui_development` sub-workflow's system prompt +# (app/workflows/living_ui/workflow.py) — the creation task runs with that +# prompt instead of the general agent's, so rules are stated once and +# cached, not re-rendered per turn. If you change the workflow's rules, +# change them THERE. LIVING_UI_TASK_INSTRUCTION = """Create a Living UI application. Project ID: {project_id} @@ -29,260 +32,7 @@ describe_image: {reference_files} -The user WATCHES this app being built in a live preview — the screen must -visibly change every few minutes, so after the wireframe you build -incrementally, bringing one capability fully to life at a time (its data, -endpoints, and UI together, mounted as you go) rather than doing all -backend then all frontend, which freezes the preview for a long stretch. -Follow the living-ui-creator skill instructions. Here's the workflow: - -1. Read agent_file_system/GLOBAL_LIVING_UI.md — apply its colors, fonts, - and rules — and study the reference files listed above (describe_image - for images). Do NOT call set_requirement (the REQUIREMENTS section - above is the requirement record; skip that step entirely) and NEVER - ask the user questions — requirement gathering already happened in the - creation wizard. -2. CREATE THE TODO LIST NOW — immediately after that, call - task_update_todos with the FULL plan, shaped to fit the app (the skill - shows a sensible default; adapt it). The todo list is the user's - progress display from the first second; nothing else happens before it - exists. -3. LAYOUT WIREFRAME FIRST (before ANY backend work): rewrite MainView.tsx as - a TEXTLESS LAYOUT KIT assembly — with one
per - planned region (NO title/meta), each holding Skeleton* blocks ARRANGED - to match that region's intended shape (thin row for tabs, CardGrid of - SkeletonCards for a feed, short blocks for stats). There is NO page - header/title band — the page starts with its content Sections. The - wireframe contains NO text, NO titles, NO labels, NO interactive - elements — it purely books each region's area and general layout. - Every part MUST be replaced as features complete (features add real - titles/actions); leftover Skeletons in MainView FAIL validation. The - platform flags backend writes made before the wireframe exists. -4. BUILD THE APP to fulfill the REQUIREMENTS — work the way you would in - any codebase; there is NO forced backend-then-frontend order: - - THE BACKEND IS DECLARED, NOT CODED: define every entity in - config/schema.json and the engine materializes the models AND a full - REST API per entity at startup (GET/POST/PUT/DELETE /api/, - bulk insert, equality filters + ?q= search + range filters - (_gte/_lte) + orderBy/order/limit/offset + /_stats - aggregations, camelCase JSON, automatic id/createdAt/updatedAt — do - NOT declare those three). Field types include enum (values list, - union-typed) and "unique": true constraints. - You never write models.py or CRUD routes. Hand-write ONLY behavior - the generated CRUD cannot express, as custom endpoints in routes.py - (paths WITHOUT /api — the router adds it), each with a one-line - docstring, each declared as an op in config/operations.json. - - GENERATED, NEVER HAND-WRITTEN: entity TypeScript types live in - frontend/types.gen.ts (regenerated from schema.json on every schema - write) — import them. Data plumbing is provided: useEntities from - '../services/data' (items + create/update/remove with auto-refresh) - and the `data` client for one-off calls. Do NOT write per-entity - fetch methods; ApiService is only for CUSTOM endpoints. - - EVERY ENTITY NEEDS A WORKING INGRESS: before building, state how - each entity's records enter the app (user forms / bridge pull from a - connected service / file import / computed). Inbound webhooks are NOT - an ingress — the app runs on localhost and external services can - never reach it. If the REQUIREMENTS demand external data, build the - bridge pull (services/integration_client) plus a scheduled sync op; - a dashboard that can never contain data is a failed build even if - validation passes. - - DEPENDENCIES ARE THE PLATFORM'S JOB: never run npm install / pip - install — installs run automatically from project creation onward. - Early "Cannot find module" type errors on TEMPLATE deps mean the - install is still running: keep building, the note clears itself; a - second concurrent npm CORRUPTS node_modules and kills the preview. - New packages: add to package.json/requirements.txt and validation - installs them. Schema field renames/removals are safe — the platform - reconciles the DB (never hand-edit/delete living_ui.db). - - FILE STORAGE IS BUILT IN: system routes POST/GET/DELETE /api/files - (+ /api/files/{{id}} serving) with / presets and - files/fileUrl helpers in services/data — never hand-roll uploads; - store the returned url string in a schema string field. - - SCHEDULED OPS: add "schedule": "every 15m" | "hourly" | "daily 09:00" - to any op in operations.json and the platform runs it while the app - is up (defaults must satisfy params; results in logs/schedule.log). - - IN-APP AI: await integration.llm(prompt) / .describe_image(url) from - services/integration_client — CraftBot's models, no API keys; handle - the empty-string failure case in the UI. - - SECRETS: user-provided API keys live ONLY in backend/.env, read via - services/secrets.get_secret — never hardcoded, never printed. - CraftBot-connected services need no keys (integration bridge). - - EXTERNAL DATABASE (Supabase/Postgres): to run the app on Supabase, - write backend/.env with one line: DATABASE_URL=postgresql://... - (ask the user for their connection string; never hardcode or echo it - — it contains the password). The engine, migration, and CLI follow it - automatically; psycopg2 is preinstalled. Remote data is real — warn - before reset/destructive ops. Delete backend/.env to go back to - SQLite. - - SCHEMA-AWARE PRESETS FIRST: IS the - create/edit form (right input per field type, required validation, - ref dropdowns); IS the data table - (sortable, delete-confirmed). Plus useConfirm() (never browser - confirm()), SortableList + reorderAndSave (drag reorder), - SearchInput (pairs with ?q=), DateInput/NumberInput/TagInput, - useHotkey/useDebounce, toast.success/error (confirm EVERY mutation; - AppShell hosts it), DropdownMenu (row actions), Drawer (detail/edit - panel), SegmentedControl (enum filters), Sparkline/MiniBarChart (for - /_stats), Tooltip/Pagination/ProgressBar/Spinner/Kbd/Avatar — all - from './components/ui'. EntityTable also takes searchable + pageSize. - Hand-roll a form or table ONLY when the preset genuinely cannot - express it. - - STYLE PACKS — THE HOST OWNS THEMING: the template ships 4 design - languages (default/modern/glass/classic) as token overrides in - frontend/styles/themes.css. The user picks a THEME from the Living - UI top bar's picker (each theme bundles a style pack + palette); - dark/light follows the browser interface. NEVER render a theme picker, style switcher, or dark-mode - toggle inside the app — it is a defect. If the user wants a specific - look, call setDefaultStyle('glass') once at the top of App.tsx (yields - to the user's choice). NEVER hardcode radius/shadow/blur/spacing — - tokens only, or the packs break. - - ACCENT DISCIPLINE: the primary (orange) accent is for ONE main action - per view + active/selected states ONLY. Button defaults to secondary — - opt into variant="primary" deliberately. Vary StatCard/IconBadge/chart - colors with semantic tokens (info/success/warning/error) instead of - repeating the accent everywhere. - - READ before you write: config/schema.json, LIVING_UI.md, and the - files you're about to change (routes.py, the components) — and - follow their conventions. - - Build INCREMENTALLY so the preview keeps changing: take one - capability at a time all the way to working (its schema entities, any - custom endpoint, and its UI, mounting each component as you write - it). Don't do all backend then all frontend — that leaves the screen - frozen. - - FIX FEEDBACK IMMEDIATELY: after frontend writes the platform runs the - project's own tsc and puts the COMPLETE type-error list in your write - result. Fix all of them in your next step — they never age well. - - RUN tests / lint / the build when it helps you catch a problem early - (find the project's commands first). You are NOT required to per - step — living_ui_validate runs the full suite at the end. - - Every capability must WORK END TO END in the running app: the control - opens a real form/modal (never a browser prompt/confirm), submits to - the backend, and the view updates. Renders-but-does-nothing is NOT - done. - Rules that always hold: NEVER seed fake data to pass a test; every - component is written ONCE in final form (no static stubs, no - placeholder handlers like onClick={{() => {{}}}}); style internals with - the token-mapped Tailwind utilities (bg-surface, text-ink-secondary, - border-line, bg-primary, ...) — a scoped - - ) -} - -// ============================================================================= -// Section — titled content block with consistent spacing + its own actions -// ============================================================================= - -export interface SectionProps { - title?: ReactNode - /** Small text right of the title (counts, hints). */ - meta?: ReactNode - /** Section-scoped actions (buttons/filters for THIS section only). */ - actions?: ReactNode - children: ReactNode -} - -export function Section({ title, meta, actions, children }: SectionProps) { - return ( -
- {(title || actions || meta) && ( -
-
- {title &&

{title}

} - {meta && {meta}} -
- {actions &&
{actions}
} -
- )} -
{children}
- -
- ) -} - -// ============================================================================= -// CardGrid — responsive card layout with correct gaps -// ============================================================================= - -export interface CardGridProps { - children: ReactNode - /** Minimum card width in px before wrapping (default 260). */ - minWidth?: number -} - -export function CardGrid({ children, minWidth = 260 }: CardGridProps) { - return ( -
- {children} - -
- ) -} - -// ============================================================================= -// Toolbar — one horizontal row of controls with correct gaps and wrapping -// ============================================================================= - -export interface ToolbarProps { - children: ReactNode - /** Push a trailing group to the right edge. */ - end?: ReactNode -} - -export function Toolbar({ children, end }: ToolbarProps) { - return ( -
-
{children}
- {end &&
{end}
} - -
- ) -} - -// ============================================================================= -// IconBadge — colored icon holder; the cheapest way to make UI non-text-only -// ============================================================================= - -export interface IconBadgeProps { - /** A lucide-react icon element, e.g. . */ - icon: ReactNode - /** Color token or css color. Default is NEUTRAL — pass semantic colors - * (var(--color-info), var(--color-success), …) to differentiate stats; - * don't make every badge the primary accent. */ - color?: string - /** Diameter in px (default 36). */ - size?: number -} - -/** Token names agents naturally write ("warning") resolve to their CSS - * variables — a bare token name would otherwise be an invalid color and - * silently render untinted. */ -function resolveAccent(color: string): string { - return ['primary', 'success', 'warning', 'error', 'info'].includes(color) - ? `var(--color-${color})` - : color -} - -export function IconBadge({ icon, color = 'var(--text-secondary)', size = 36 }: IconBadgeProps) { - color = resolveAccent(color) - return ( - - {icon} - - - ) -} - -// ============================================================================= -// StatCard — icon + big value + label; dashboards stop being walls of text -// ============================================================================= - -export interface StatCardProps { - /** A lucide-react icon element. */ - icon?: ReactNode - value: ReactNode - label: ReactNode - /** Accent color for the icon badge. */ - color?: string -} - -export function StatCard({ icon, value, label, color }: StatCardProps) { - return ( -
- {icon && } -
-
{value}
-
{label}
-
- -
- ) -} - -// ============================================================================= -// SplitView — main content + fixed-width aside, collapses on narrow screens -// ============================================================================= - -export interface SplitViewProps { - children: ReactNode - aside: ReactNode - /** Aside width in px (default 320). */ - asideWidth?: number -} - -export function SplitView({ children, aside, asideWidth = 320 }: SplitViewProps) { - return ( -
-
{children}
-
- {aside} -
- -
- ) -} - -// ============================================================================= -// Skeletons — intentional shimmer placeholders for wireframes/loading -// (For the no-data pattern use the existing preset from index.) -// ============================================================================= - -/** - * ALL skeletons are ADAPTIVE: they size from their container (width 100%, - * aspect-ratio or em heights), never from px props — a skeleton can never - * overflow its parent. Adjacent skeletons space themselves automatically; - * use to group mixed shapes with consistent rhythm. - * - * The wireframe vocabulary (Phase 1.5 uses ONLY these — never hand-made - * shimmer divs, inline styles, or px sizes): - * SkeletonBox plain rectangle (ratio = width/height proportion) - * SkeletonCircle circle (sm | md | lg, em-scaled) - * SkeletonText paragraph lines (staggered widths) - * SkeletonChip pill row (filters/tags placeholder) - * SkeletonCard media card (aspect media + text lines) - * SkeletonRow list rows - * SkeletonStack vertical group with kit spacing - */ - -export interface SkeletonBoxProps { - /** How many boxes (default 1). */ - count?: number - /** Width/height proportion, e.g. 3 = wide strip, 1 = square (default 3). */ - ratio?: number -} - -export function SkeletonBox({ count = 1, ratio = 3 }: SkeletonBoxProps) { - return ( - <> - {Array.from({ length: count }, (_, i) => ( -
- ))} - - - ) -} - -export interface SkeletonCircleProps { - count?: number - /** Diameter, type-scaled: 'sm' (avatar) | 'md' | 'lg' (default 'md'). */ - size?: 'sm' | 'md' | 'lg' -} - -export function SkeletonCircle({ count = 1, size = 'md' }: SkeletonCircleProps) { - return ( -
- {Array.from({ length: count }, (_, i) => ( -
- ))} - -
- ) -} - -export interface SkeletonTextProps { - /** Number of text lines (default 3). Widths stagger automatically. */ - lines?: number -} - -export function SkeletonText({ lines = 3 }: SkeletonTextProps) { - const widths = ['92%', '78%', '85%', '64%', '88%', '71%'] - return ( -
- {Array.from({ length: lines }, (_, i) => ( -
- ))} - -
- ) -} - -export interface SkeletonChipProps { - /** Number of pills (default 3). */ - count?: number -} - -export function SkeletonChip({ count = 3 }: SkeletonChipProps) { - return ( -
- {Array.from({ length: count }, (_, i) => ( -
- ))} - -
- ) -} - -export interface SkeletonCardProps { - /** How many placeholder cards to render (default 1). */ - count?: number - /** Text lines under the media block (default 2). */ - lines?: number - /** Render the media block (default true). */ - media?: boolean -} - -export function SkeletonCard({ count = 1, lines = 2, media = true }: SkeletonCardProps) { - const widths = ['72%', '48%', '84%', '56%'] - return ( - <> - {Array.from({ length: count }, (_, i) => ( -
- {media &&
} - {Array.from({ length: lines }, (_, j) => ( -
- ))} -
- ))} - - - ) -} - -export interface SkeletonRowProps { - count?: number -} - -export function SkeletonRow({ count = 3 }: SkeletonRowProps) { - return ( -
- {Array.from({ length: count }, (_, i) => ( -
- ))} - -
- ) -} - -export interface SkeletonStackProps { - /** Mixed skeleton shapes, stacked with consistent kit spacing. */ - children: ReactNode -} - -export function SkeletonStack({ children }: SkeletonStackProps) { - return ( -
- {children} - -
- ) -} - -function SkeletonStyles() { - return ( - - ) -} diff --git a/app/data/living_ui_template/frontend/components/ui/menu.tsx b/app/data/living_ui_template/frontend/components/ui/menu.tsx deleted file mode 100644 index 025e435c..00000000 --- a/app/data/living_ui_template/frontend/components/ui/menu.tsx +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Dropdown action menu (SYSTEM-MANAGED — do not edit) - * - * The "⋯" row-actions pattern — one trigger, a list of actions: - * - * } />} - * items={[ - * { label: 'Edit', icon: , onSelect: () => openEdit(card) }, - * { label: 'Duplicate', onSelect: () => duplicate(card) }, - * { label: 'Delete', danger: true, onSelect: () => remove(card) }, - * ]} - * /> - * - * Closes on selection, outside click, and Escape. - */ - -import React, { useEffect, useRef, useState } from 'react' -import { useDevSurface } from './devtour' - -export interface DropdownMenuItem { - label: string - icon?: React.ReactNode - onSelect: () => void - danger?: boolean - disabled?: boolean -} - -export interface DropdownMenuProps { - /** The element that opens the menu (a Button, an icon, …). */ - trigger: React.ReactNode - items: DropdownMenuItem[] - /** Which edge of the trigger the menu aligns to (default 'right'). */ - align?: 'left' | 'right' -} - -export function DropdownMenu({ trigger, items, align = 'right' }: DropdownMenuProps) { - const [open, setOpen] = useState(false) - const rootRef = useRef(null) - - // Build-time tour (dev only): briefly open the menu to show its items. - // Renders inline (inside #root), so the reveal engine captures it itself. - useDevSurface( - 'menu', - items[0]?.label ? `Menu (${items[0].label}…)` : 'Menu', - () => setOpen(true), - () => setOpen(false), - ) - - useEffect(() => { - if (!open) return - const onDocClick = (e: MouseEvent) => { - if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false) - } - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setOpen(false) - } - document.addEventListener('mousedown', onDocClick) - document.addEventListener('keydown', onKey) - return () => { - document.removeEventListener('mousedown', onDocClick) - document.removeEventListener('keydown', onKey) - } - }, [open]) - - return ( -
- { - e.stopPropagation() - setOpen(o => !o) - }} - style={{ display: 'inline-flex', cursor: 'pointer' }} - aria-haspopup="menu" - aria-expanded={open} - > - {trigger} - - {open && ( -
- {items.map((item, i) => ( - - ))} -
- )} -
- ) -} diff --git a/app/data/living_ui_template/frontend/components/ui/menubar.tsx b/app/data/living_ui_template/frontend/components/ui/menubar.tsx new file mode 100644 index 00000000..92acbb0a --- /dev/null +++ b/app/data/living_ui_template/frontend/components/ui/menubar.tsx @@ -0,0 +1,254 @@ +import * as React from "react" +import * as MenubarPrimitive from "@radix-ui/react-menubar" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +function MenubarMenu({ + ...props +}: React.ComponentProps) { + return +} + +function MenubarGroup({ + ...props +}: React.ComponentProps) { + return +} + +function MenubarPortal({ + ...props +}: React.ComponentProps) { + return +} + +function MenubarRadioGroup({ + ...props +}: React.ComponentProps) { + return +} + +function MenubarSub({ + ...props +}: React.ComponentProps) { + return +} + +const Menubar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Menubar.displayName = MenubarPrimitive.Root.displayName + +const MenubarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName + +const MenubarSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName + +const MenubarSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName + +const MenubarContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, + ref + ) => ( + + + + ) +) +MenubarContent.displayName = MenubarPrimitive.Content.displayName + +const MenubarItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +MenubarItem.displayName = MenubarPrimitive.Item.displayName + +const MenubarCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName + +const MenubarRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName + +const MenubarLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +MenubarLabel.displayName = MenubarPrimitive.Label.displayName + +const MenubarSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName + +const MenubarShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +MenubarShortcut.displayname = "MenubarShortcut" + +export { + Menubar, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarItem, + MenubarSeparator, + MenubarLabel, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, + MenubarPortal, + MenubarSubContent, + MenubarSubTrigger, + MenubarGroup, + MenubarSub, + MenubarShortcut, +} diff --git a/app/data/living_ui_template/frontend/components/ui/navigation-menu.tsx b/app/data/living_ui_template/frontend/components/ui/navigation-menu.tsx new file mode 100644 index 00000000..411f5192 --- /dev/null +++ b/app/data/living_ui_template/frontend/components/ui/navigation-menu.tsx @@ -0,0 +1,128 @@ +import * as React from "react" +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" +import { cva } from "class-variance-authority" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const NavigationMenu = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + +)) +NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName + +const NavigationMenuList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName + +const NavigationMenuItem = NavigationMenuPrimitive.Item + +const navigationMenuTriggerStyle = cva( + "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent" +) + +const NavigationMenuTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children}{" "} + +)) +NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName + +const NavigationMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName + +const NavigationMenuLink = NavigationMenuPrimitive.Link + +const NavigationMenuViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ +
+)) +NavigationMenuViewport.displayName = + NavigationMenuPrimitive.Viewport.displayName + +const NavigationMenuIndicator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +
+ +)) +NavigationMenuIndicator.displayName = + NavigationMenuPrimitive.Indicator.displayName + +export { + navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, +} diff --git a/app/data/living_ui_template/frontend/components/ui/pagination.tsx b/app/data/living_ui_template/frontend/components/ui/pagination.tsx new file mode 100644 index 00000000..ea40d196 --- /dev/null +++ b/app/data/living_ui_template/frontend/components/ui/pagination.tsx @@ -0,0 +1,117 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +