From 91962ba879a677e0fcf43bc2e1dc7abe0732b462 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 28 May 2026 02:48:47 +0200 Subject: [PATCH] fix automation run inputs, hitl routing, and detail UI polish --- .../main_agent/tools/automation/create.py | 11 +- .../hitl/approvals/self_gated/request.py | 7 + .../app/automations/dispatch/run.py | 9 +- .../automation-detail-content.tsx | 2 - .../automation-triggers-section.tsx | 33 +-- .../components/trigger-card.tsx | 29 ++- .../tool-ui/automation/create-automation.tsx | 213 ++++++++++++++---- surfsense_web/lib/format-date.ts | 40 +++- 8 files changed, 258 insertions(+), 86 deletions(-) diff --git a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/create.py b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/create.py index 07b579f3b..173d302e5 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/create.py +++ b/surfsense_backend/app/agents/multi_agent_chat/main_agent/tools/automation/create.py @@ -22,11 +22,14 @@ from typing import Any from uuid import UUID from fastapi import HTTPException +from langchain.tools import ToolRuntime from langchain_core.messages import HumanMessage from langchain_core.tools import tool from pydantic import ValidationError -from app.agents.new_chat.tools.hitl import request_approval +from app.agents.multi_agent_chat.subagents.shared.hitl.approvals.self_gated import ( + request_approval, +) from app.automations.schemas.api import AutomationCreate from app.automations.services.automation import AutomationService from app.db import User, async_session_maker @@ -56,7 +59,7 @@ def create_create_automation_tool( uid = UUID(user_id) if isinstance(user_id, str) else user_id @tool - async def create_automation(intent: str) -> dict[str, Any]: + async def create_automation(intent: str, runtime: ToolRuntime) -> dict[str, Any]: """Draft + save an automation from a natural-language intent. Use this when the user wants SurfSense to do something on its own @@ -137,6 +140,7 @@ def create_create_automation_tool( tool_name="create_automation", params=card_params, context={"search_space_id": search_space_id}, + tool_call_id=runtime.tool_call_id, ) if result.rejected: @@ -200,6 +204,5 @@ def _extract_json(text: str) -> dict[str, Any] | None: def _format_validation_issues(exc: ValidationError) -> list[str]: return [ - f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" - for err in exc.errors() + f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in exc.errors() ] diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/hitl/approvals/self_gated/request.py b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/hitl/approvals/self_gated/request.py index 8729ea85b..2f7e3cd35 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/hitl/approvals/self_gated/request.py +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/shared/hitl/approvals/self_gated/request.py @@ -49,6 +49,7 @@ def request_approval( params: dict[str, Any], context: dict[str, Any] | None = None, trusted_tools: list[str] | None = None, + tool_call_id: str | None = None, ) -> HITLResult: """Pause the graph for user approval and return the user's decision. @@ -64,6 +65,10 @@ def request_approval( forwarded verbatim to the FE for richer card chrome. trusted_tools: Per-session allowlist; when ``tool_name`` is in it the interrupt is skipped and the tool runs immediately. + tool_call_id: Caller's LangChain tool-call id. Required for tools + running directly on the main agent; subagent-mounted tools omit + it (the ``task`` chokepoint stamps it on re-raise — see + :mod:`...checkpointed_subagent_middleware.propagation`). Returns: :class:`HITLResult` with ``rejected=True`` if the user declined or @@ -90,6 +95,8 @@ def request_approval( interrupt_type=action_type, context=context, ) + if tool_call_id: + payload["tool_call_id"] = tool_call_id approval = interrupt(payload) parsed = parse_lc_envelope(approval) diff --git a/surfsense_backend/app/automations/dispatch/run.py b/surfsense_backend/app/automations/dispatch/run.py index e317a13b9..02d0b0356 100644 --- a/surfsense_backend/app/automations/dispatch/run.py +++ b/surfsense_backend/app/automations/dispatch/run.py @@ -67,8 +67,15 @@ async def dispatch_run( def _validate_inputs( definition: AutomationDefinition, inputs: dict[str, Any] ) -> dict[str, Any]: + """Validate merged inputs against the optional declared schema. + + No declared schema → pass through (runtime inputs like ``fired_at`` / + ``last_fired_at`` and trigger ``static_inputs`` must still reach the + template context). Returning ``{}`` here strips them and makes Jinja + blow up on any ``{{ inputs.* }}`` reference. + """ if definition.inputs is None or not definition.inputs.schema_: - return {} + return inputs try: jsonschema.validate(instance=inputs, schema=definition.inputs.schema_) except jsonschema.ValidationError as exc: diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/automation-detail-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/automation-detail-content.tsx index 253d6ae67..49df3633e 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/automation-detail-content.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/automation-detail-content.tsx @@ -77,10 +77,8 @@ export function AutomationDetailContent({ diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx index 8cc62f5c8..33c8373a1 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/automation-triggers-section.tsx @@ -1,7 +1,5 @@ "use client"; -import { CalendarClock, MessageSquarePlus } from "lucide-react"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; +import { CalendarClock } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { Trigger } from "@/contracts/types/automation.types"; import { TriggerCard } from "./trigger-card"; @@ -9,43 +7,28 @@ import { TriggerCard } from "./trigger-card"; interface AutomationTriggersSectionProps { triggers: Trigger[]; automationId: number; - searchSpaceId: number; canUpdate: boolean; canDelete: boolean; - canCreate: boolean; } /** * The Triggers card. Lists each attached trigger with its own enable - * toggle and remove button. Adding a new trigger is intent-driven (via - * chat) for v1 — same philosophy as creating an automation, so the - * empty/add CTA links to a new chat rather than opening a form. + * toggle and remove button. v1 attaches triggers at automation-creation + * time only; there is no in-place "add trigger" affordance here. */ export function AutomationTriggersSection({ triggers, automationId, - searchSpaceId, canUpdate, canDelete, - canCreate, }: AutomationTriggersSectionProps) { return ( - -
- Triggers -

- When this automation fires. v1 supports scheduled triggers only. -

-
- {canCreate && ( - - )} + + Triggers +

+ When this automation fires. v1 supports scheduled triggers only. +

{triggers.length === 0 ? ( diff --git a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx index afadf589a..ec0246e49 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/automations/[automation_id]/components/trigger-card.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import type { Trigger } from "@/contracts/types/automation.types"; import { describeCron } from "@/lib/automations/describe-cron"; -import { formatRelativeDate } from "@/lib/format-date"; +import { formatRelativeDate, formatRelativeFutureDate } from "@/lib/format-date"; import { DeleteTriggerDialog } from "./delete-trigger-dialog"; interface TriggerCardProps { @@ -91,11 +91,18 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
{(trigger.last_fired_at || trigger.next_fire_at) && ( -
+
{trigger.next_fire_at && ( - + + )} + {trigger.last_fired_at && ( + )} - {trigger.last_fired_at && }
)} @@ -126,17 +133,20 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri function TimeRow({ label, iso, + tense, highlight = false, }: { label: string; iso: string; + tense: "past" | "future"; highlight?: boolean; }) { + const formatted = tense === "future" ? formatRelativeFutureDate(iso) : formatRelativeDate(iso); return ( -
-
+ <> +
- {label}: + {label}
- {formatRelativeDate(iso)} + {formatted}
-
+ ); } diff --git a/surfsense_web/components/tool-ui/automation/create-automation.tsx b/surfsense_web/components/tool-ui/automation/create-automation.tsx index 713c5fd46..00b120d38 100644 --- a/surfsense_web/components/tool-ui/automation/create-automation.tsx +++ b/surfsense_web/components/tool-ui/automation/create-automation.tsx @@ -2,16 +2,26 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; -import { CornerDownLeftIcon, ExternalLink, Workflow } from "lucide-react"; +import { + AlertCircle, + Code, + CornerDownLeftIcon, + ExternalLink, + Pencil, + Workflow, +} from "lucide-react"; import Link from "next/link"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { TextShimmerLoader } from "@/components/prompt-kit/loader"; import { Button } from "@/components/ui/button"; +import { automationCreateRequest } from "@/contracts/types/automation.types"; import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl"; import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl"; import { AutomationDraftPreview } from "./automation-draft-preview"; +const editArgsSchema = automationCreateRequest.omit({ search_space_id: true }); + // ---------------------------------------------------------------------------- // Result discrimination — mirrors the backend return shapes in // app/agents/multi_agent_chat/main_agent/tools/automation/create.py. @@ -62,12 +72,11 @@ function hasStatus(value: unknown, status: string): boolean { // ---------------------------------------------------------------------------- // Approval card — pending → processing → complete / rejected. // -// v1 deliberately supports only approve/reject. The drafted JSON is complex -// (full plan + triggers) and we already have a multi-turn refinement path via -// chat ("make it run at 10am instead" → the agent re-calls the tool with a -// refined intent). An in-card edit form would duplicate that flow and add UX -// surface area we don't need yet — leave it for the raw-JSON path on the -// detail page. +// Edit toggle reuses the same primitives as the Create-via-JSON page: raw +// textarea, Format, Zod validation against ``AutomationCreate`` (minus the +// ``search_space_id`` field, which the backend injects). Approve dispatches +// an ``edit`` decision with the parsed args when edits are pending, otherwise +// a plain ``approve``. Multi-turn chat refinement still works as a fallback. // ---------------------------------------------------------------------------- interface ApprovalCardProps { @@ -83,28 +92,34 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) { const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; const canApprove = allowedDecisions.includes("approve"); const canReject = allowedDecisions.includes("reject"); + const canEdit = allowedDecisions.includes("edit"); - const draft = useMemo(() => extractDraft(args), [args]); + const [pendingEdits, setPendingEdits] = useState | null>(null); + const [isEditing, setIsEditing] = useState(false); + + const effectiveArgs = pendingEdits ?? args; + const draft = useMemo(() => extractDraft(effectiveArgs), [effectiveArgs]); const handleApprove = useCallback(() => { - if (phase !== "pending" || !canApprove) return; + if (phase !== "pending" || !canApprove || isEditing) return; setProcessing(); onDecision({ - type: "approve", + type: pendingEdits ? "edit" : "approve", edited_action: { name: interruptData.action_requests[0]?.name ?? "create_automation", - args, + args: pendingEdits ?? args, }, }); - }, [phase, canApprove, setProcessing, onDecision, interruptData, args]); + }, [phase, canApprove, isEditing, setProcessing, onDecision, interruptData, args, pendingEdits]); const handleReject = useCallback(() => { - if (phase !== "pending" || !canReject) return; + if (phase !== "pending" || !canReject || isEditing) return; setRejected(); onDecision({ type: "reject", message: "User rejected the automation draft." }); - }, [phase, canReject, setRejected, onDecision]); + }, [phase, canReject, isEditing, setRejected, onDecision]); useEffect(() => { + if (isEditing) return; const handler = (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) { handleApprove(); @@ -112,46 +127,77 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) { }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [handleApprove]); + }, [handleApprove, isEditing]); return (
-
- -
-

- {phase === "rejected" - ? "Automation cancelled" - : phase === "processing" - ? "Saving automation" - : phase === "complete" - ? "Automation saved" - : "Create automation"} -

- {phase === "processing" ? ( - - ) : phase === "complete" ? ( -

- Automation created from this draft +

+
+ +
+

+ {phase === "rejected" + ? "Automation cancelled" + : phase === "processing" + ? "Saving automation" + : phase === "complete" + ? "Automation saved" + : "Create automation"}

- ) : phase === "rejected" ? ( -

- No automation was saved — ask in chat to refine and try again. -

- ) : ( -

- Review and approve to save. To change anything, reply in chat — I'll redraft. -

- )} + {phase === "processing" ? ( + + ) : phase === "complete" ? ( +

+ {pendingEdits + ? "Automation saved with your edits" + : "Automation created from this draft"} +

+ ) : phase === "rejected" ? ( +

+ No automation was saved — ask in chat to refine and try again. +

+ ) : ( +

+ {pendingEdits + ? "Showing your edits. Approve to save, or edit again." + : "Review and approve to save. Edit for fine-tuning, or reply in chat for a redraft."} +

+ )} +
+ {phase === "pending" && canEdit && !isEditing && ( + + )}
- + {isEditing ? ( + { + setPendingEdits(parsed); + setIsEditing(false); + }} + onCancel={() => setIsEditing(false)} + /> + ) : ( + + )}
- {phase === "pending" && ( + {phase === "pending" && !isEditing && ( <>
@@ -178,6 +224,85 @@ function ApprovalCard({ args, interruptData, onDecision }: ApprovalCardProps) { ); } +interface JsonEditorProps { + initialValue: Record; + onSave: (parsed: Record) => void; + onCancel: () => void; +} + +function JsonEditor({ initialValue, onSave, onCancel }: JsonEditorProps) { + const [text, setText] = useState(() => JSON.stringify(initialValue, null, 2)); + const [issues, setIssues] = useState([]); + + function handleFormat() { + try { + setText(JSON.stringify(JSON.parse(text), null, 2)); + setIssues([]); + } catch (err) { + setIssues([`Cannot format — not valid JSON: ${(err as Error).message}`]); + } + } + + function handleSave() { + setIssues([]); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + setIssues([`Invalid JSON: ${(err as Error).message}`]); + return; + } + const result = editArgsSchema.safeParse(parsed); + if (!result.success) { + setIssues( + result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) + ); + return; + } + onSave(result.data as unknown as Record); + } + + return ( +
+