diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index b8acd7c9..fcaa9c1d 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -237,6 +237,32 @@ TOOLS = [ "required": ["workflow_id"], }, }, + { + "name": "SuggestConvertToWorkflow", + "description": ( + "Call this at the end of a response when you have just completed a task " + "the user is likely to want to repeat on a schedule (e.g. a daily report, " + "a weekly digest, a recurring data check, a monitoring ping). Do NOT call " + "it for one-off tasks, debugging sessions, creative work, or anything " + "where 'repeat it tomorrow' would be odd. Use sparingly — once per session " + "maximum, only with high confidence. This nudges the frontend to highlight " + "the 'Convert to Workflow' button and suggest a cadence." + ), + "inputSchema": { + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "A brief, user-friendly explanation of why this task is a good candidate for a recurring workflow (e.g. 'This is a daily report that stays the same'). Shown in the tool bubble.", + }, + "suggested_cadence": { + "type": "string", + "description": "Optional freeform cadence hint (e.g. 'every weekday morning at 9am' or 'weekly on Monday'). Leave blank if uncertain. The frontend will parse it to prefill the schedule.", + }, + }, + "required": ["reason"], + }, + }, ] @@ -507,6 +533,15 @@ def handle_read_test_transcript(args: dict) -> dict: return _ok(f"Test Agent transcript (status: {status}):\n\n{transcript}") +def handle_suggest_convert_to_workflow(args: dict) -> dict: + reason = (args.get("reason") or "").strip() + if not reason: + return _err("reason is required.") + cadence = (args.get("suggested_cadence") or "").strip() + result = json.dumps({"reason": reason, "cadence": cadence}) + return {"content": [{"type": "text", "text": result}]} + + HANDLERS = { "ScheduleWorkflow": handle_schedule_workflow, "ListScheduledWorkflows": handle_list, @@ -520,6 +555,7 @@ HANDLERS = { "DeleteWorkflowStep": handle_delete_step, "TestWorkflow": handle_test_workflow, "ReadTestTranscript": handle_read_test_transcript, + "SuggestConvertToWorkflow": handle_suggest_convert_to_workflow, } diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index f8604867..8959cf48 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -68,6 +68,29 @@ function extractStepsFromSession(session: { messages: Array<{ role: string; cont return out; } +/** Detect if the session has a completed SuggestConvertToWorkflow tool call. */ +function findWorkflowSuggestion(session: AgentSession): { reason: string; cadence: string } | null { + for (const msg of session.messages || []) { + if (msg.role !== 'assistant') continue; + const content = Array.isArray(msg.content) ? msg.content : []; + for (const block of content) { + if (block?.type === 'tool_result' && block?.tool_name === 'SuggestConvertToWorkflow') { + const mcpServer = (block as any)?.mcpServer || ''; + if (!mcpServer.includes('openswarm-schedule')) continue; + const text = block?.content?.[0]?.text; + if (!text) continue; + try { + const parsed = JSON.parse(text); + if (parsed?.reason) return { reason: parsed.reason, cadence: parsed.cadence || '' }; + } catch { + // Ignore parse errors + } + } + } + } + return null; +} + const GoogleServiceIcon: React.FC<{ service: string; size?: number }> = ({ service, size = 16 }) => { if (service === 'gmail') { return ( @@ -259,6 +282,8 @@ const AgentCard: React.FC = ({ const defaultModel = useAppSelector((s) => s.settings.data.default_model); const defaultMode = useAppSelector((s) => s.settings.data.default_mode); const [converting, setConverting] = useState(false); + const [suggestAnimationFired, setSuggestAnimationFired] = useState(false); + const workflowSuggestion = useMemo(() => findWorkflowSuggestion(session), [session]); // Hide the "Convert to workflow" button when this chat is already // entangled with a workflow (Image #44 note). Two cases: // (a) The session is one of a workflow's runner sessions, OR @@ -318,6 +343,15 @@ const AgentCard: React.FC = ({ }, [session.model, modelsByProvider]); const scrollOverlayRef = useOverlayScrollPassthrough(isSelected); + const suggestGlowRef = useRef(false); + useEffect(() => { + if (workflowSuggestion && !suggestAnimationFired && !suggestGlowRef.current) { + suggestGlowRef.current = true; + setSuggestAnimationFired(true); + dispatch(fadeGlowingAgentCard(session.id, 3000)); + } + }, [workflowSuggestion, suggestAnimationFired, dispatch, session.id]); + const cardBoxRef = useRef(null); // Ref so ResizeObserver sees latest value without re-attaching when active flips. const isDashboardActiveRef = useRef(isDashboardActive); @@ -907,7 +941,7 @@ const AgentCard: React.FC = ({ )} - {(session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && !isWorkflowRunnerSession && ( + {((session.status === 'completed' || session.status === 'stopped') && session.messages.length >= 2 && !isWorkflowRunnerSession || !!workflowSuggestion) && ( = ({ if (steps.length === 0) return; setConverting(true); const draftId = `draft-${session.id}-${Date.now()}`; - // The chat card becomes a temporary workflow draft in the - // same slot. Nothing is persisted until the user chooses - // Save Draft or Schedule Workflow from the draft card. dispatch(addWorkflowCard({ workflowId: draftId, sourceSessionId: session.id, expandedSessionIds })); dispatch(setWorkflowCardPosition({ workflowId: draftId, x: cardX, y: cardY })); dispatch(setWorkflowCardSize({ workflowId: draftId, width: cardWidth, height: cardHeight })); @@ -937,10 +968,12 @@ const AgentCard: React.FC = ({ use_synced_prompt: true, model: defaultModel || session.model, mode: defaultMode || session.mode, + suggested_cadence: workflowSuggestion?.cadence || undefined, } as Partial, })); }} onMouseDown={(e) => e.stopPropagation()} + className={workflowSuggestion && suggestAnimationFired ? 'workflow-suggest-glow' : undefined} sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, color: '#fff', @@ -952,6 +985,20 @@ const AgentCard: React.FC = ({ cursor: converting ? 'wait' : 'pointer', opacity: converting ? 0.7 : 1, '&:hover': { filter: 'brightness(1.05)' }, + '&.workflow-suggest-glow': { + animation: 'workflow-suggest-glow 600ms ease-in-out 3', + '@keyframes workflow-suggest-glow': { + '0%': { + boxShadow: `0 0 0 2px ${c.accent.primary}, 0 0 8px 2px ${c.accent.primary}88`, + }, + '50%': { + boxShadow: `0 0 0 4px ${c.accent.primary}66, 0 0 16px 4px ${c.accent.primary}44`, + }, + '100%': { + boxShadow: `0 0 0 2px ${c.accent.primary}, 0 0 8px 2px ${c.accent.primary}88`, + }, + }, + }, }} > diff --git a/frontend/src/app/pages/Workflows/SchedulingView.tsx b/frontend/src/app/pages/Workflows/SchedulingView.tsx index 8a5cccd3..ae84d56e 100644 --- a/frontend/src/app/pages/Workflows/SchedulingView.tsx +++ b/frontend/src/app/pages/Workflows/SchedulingView.tsx @@ -66,11 +66,13 @@ export default function SchedulingView({ workflow, steps }: Props) { (async () => { try { const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + const cadenceHint = workflow.suggested_cadence ? ` I think it should run ${workflow.suggested_cadence}.` : ''; + const prompt = `Greet me in one short sentence, then ask exactly: "When should this workflow run (e.g. every Wednesday at 1pm)?"${cadenceHint}`; await fetch(`${API_BASE}/agents/sessions/${encodeURIComponent(scheduleSessionId)}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(tok ? { Authorization: `Bearer ${tok}` } : {}) }, body: JSON.stringify({ - prompt: 'Greet me in one short sentence, then ask exactly: "When should this workflow run (e.g. every Wednesday at 1pm)?"', + prompt, hidden: true, }), }); diff --git a/frontend/src/shared/mcpToolMeta.ts b/frontend/src/shared/mcpToolMeta.ts index 89b6f122..4e5379f3 100644 --- a/frontend/src/shared/mcpToolMeta.ts +++ b/frontend/src/shared/mcpToolMeta.ts @@ -121,6 +121,7 @@ export function getWorkflowToolLabel(action: string): string | null { if (lower === 'testworkflow') return 'Test workflow'; if (lower === 'readtesttranscript') return 'Read test results'; if (lower === 'listworkflows') return 'List workflows'; + if (lower === 'suggestconverttoworkflow') return 'Suggest workflow'; return null; } diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index 2777b08b..b35bd1bc 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -101,6 +101,9 @@ export interface Workflow { * conversion). Compared against the current steps to decide whether to warn * before scheduling. See scheduleUtils.needsScheduleTestWarning. */ tested_signature?: string | null; + /** Suggested cadence from a SuggestConvertToWorkflow tool call (e.g. "every weekday at 9am"). + * Used to seed the scheduling agent's prompt. Transient draft field only. */ + suggested_cadence?: string; } export interface WorkflowRun {