feat(flows): Workflows B4 — agent proposes a workflow (#4472)

This commit is contained in:
Cyrus Gray
2026-07-04 01:56:34 +05:30
committed by GitHub
parent a5b6a13d5c
commit 1de7506972
27 changed files with 1211 additions and 3 deletions
@@ -0,0 +1,116 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { WorkflowProposal } from '../../store/chatRuntimeSlice';
import { WorkflowProposalCard } from './WorkflowProposalCard';
// Echo i18n keys so we can assert on the stable key string.
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
const mockCreateFlow = vi.fn();
vi.mock('../../services/api/flowsApi', () => ({
createFlow: (...args: unknown[]) => mockCreateFlow(...args),
}));
const mockDispatch = vi.fn();
vi.mock('../../store/hooks', () => ({ useAppDispatch: () => mockDispatch }));
function proposal(partial: Partial<WorkflowProposal> = {}): WorkflowProposal {
return {
name: 'Daily standup summary',
graph: { nodes: [], edges: [] },
requireApproval: true,
summary: {
trigger: 'schedule: 0 9 * * *',
steps: [
{ kind: 'agent', name: 'Summarize', config_hint: "Summarize yesterday's messages" },
{ kind: 'tool_call', name: 'Post to Slack' },
],
},
...partial,
};
}
describe('WorkflowProposalCard', () => {
beforeEach(() => {
mockCreateFlow.mockReset().mockResolvedValue({ id: 'f1', name: 'Daily standup summary' });
mockDispatch.mockReset();
});
it('renders the name, trigger, and steps with node-kind badges', () => {
render(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
expect(screen.getByText('Daily standup summary')).toBeInTheDocument();
expect(screen.getByText('schedule: 0 9 * * *')).toBeInTheDocument();
expect(screen.getByText('Summarize')).toBeInTheDocument();
expect(screen.getByText('Post to Slack')).toBeInTheDocument();
expect(screen.getByText('agent')).toBeInTheDocument();
expect(screen.getByText('tool_call')).toBeInTheDocument();
expect(screen.getAllByTestId('workflow-proposal-step-kind')).toHaveLength(2);
});
it('has the expected root test id', () => {
render(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
expect(screen.getByTestId('workflow-proposal-card')).toBeInTheDocument();
});
it('saves via createFlow with the right args and clears optimistically', async () => {
const p = proposal();
render(<WorkflowProposalCard threadId="t1" proposal={p} />);
fireEvent.click(screen.getByText('chat.flowProposal.save'));
await waitFor(() =>
expect(mockCreateFlow).toHaveBeenCalledWith(p.name, p.graph, p.requireApproval)
);
expect(mockDispatch).toHaveBeenCalledTimes(1);
});
it('shows a loading state while saving', async () => {
let resolveCreate!: (value: unknown) => void;
mockCreateFlow.mockReturnValueOnce(
new Promise(resolve => {
resolveCreate = resolve;
})
);
render(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
fireEvent.click(screen.getByText('chat.flowProposal.save'));
await waitFor(() => expect(screen.getByText('chat.flowProposal.saving')).toBeInTheDocument());
resolveCreate({ id: 'f1' });
});
it('surfaces an error and stays mounted when createFlow fails', async () => {
mockCreateFlow.mockRejectedValueOnce(new Error('boom'));
render(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
fireEvent.click(screen.getByText('chat.flowProposal.save'));
await waitFor(() => expect(screen.getByText(/chat\.flowProposal\.error/)).toBeInTheDocument());
// Not cleared on failure.
expect(mockDispatch).not.toHaveBeenCalled();
});
it('dismiss clears the proposal without calling createFlow', () => {
render(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
fireEvent.click(screen.getByText('chat.flowProposal.dismiss'));
expect(mockCreateFlow).not.toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledTimes(1);
});
it('renders a fallback message when there are no non-trigger steps', () => {
render(
<WorkflowProposalCard
threadId="t1"
proposal={proposal({ summary: { trigger: 'manual', steps: [] } })}
/>
);
expect(screen.getByText('chat.flowProposal.noSteps')).toBeInTheDocument();
});
it('shows the require-approval hint only when requireApproval is true', () => {
const { rerender } = render(
<WorkflowProposalCard threadId="t1" proposal={proposal({ requireApproval: true })} />
);
expect(screen.getByText('chat.flowProposal.requireApprovalHint')).toBeInTheDocument();
rerender(
<WorkflowProposalCard threadId="t1" proposal={proposal({ requireApproval: false })} />
);
expect(screen.queryByText('chat.flowProposal.requireApprovalHint')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,143 @@
import debug from 'debug';
import React, { useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { createFlow } from '../../services/api/flowsApi';
import {
clearWorkflowProposalForThread,
type WorkflowProposal,
} from '../../store/chatRuntimeSlice';
import { useAppDispatch } from '../../store/hooks';
import Button from '../ui/Button';
const log = debug('openhuman:chat:workflow-proposal-card');
interface Props {
threadId: string;
proposal: WorkflowProposal;
}
/**
* Human-in-the-loop gate for the `propose_workflow` agent tool (issue B4 —
* agent-first Workflow authoring). The tool only VALIDATES a candidate
* `tinyflows` graph and returns a summary — it can NEVER create or enable a
* flow itself. This card is the only path from a proposal to a saved
* automation: "Save & enable" calls `openhuman.flows_create` directly from
* the client; the agent has no way to reach that RPC on its own. "Dismiss"
* just clears the proposal without saving anything.
*
* Mirrors {@link PlanReviewCard}'s placement/chrome above the composer, and
* the tool-timeline `StatusTag`/detail-chip visual language for the
* node-kind badges + config hints in the step list.
*/
export const WorkflowProposalCard: React.FC<Props> = ({ threadId, proposal }) => {
const { t } = useT();
const dispatch = useAppDispatch();
const [saving, setSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const dismiss = () => {
dispatch(clearWorkflowProposalForThread({ threadId }));
};
const save = async () => {
if (saving) return;
setSaving(true);
setErrorMsg(null);
try {
await createFlow(proposal.name, proposal.graph, proposal.requireApproval);
dispatch(clearWorkflowProposalForThread({ threadId }));
} catch (e) {
log('createFlow failed: %o', e);
setErrorMsg(t('chat.flowProposal.error'));
setSaving(false);
}
};
return (
<div
role="group"
aria-label={t('chat.flowProposal.title')}
data-testid="workflow-proposal-card"
className="mb-2 rounded-xl border border-ocean-300 bg-surface p-3 text-sm shadow-md dark:border-ocean-700">
<div className="flex items-start gap-2">
<span aria-hidden className="text-base leading-none text-ocean-700 dark:text-ocean-200">
</span>
<div className="min-w-0 flex-1">
<p className="font-semibold text-ocean-900 dark:text-ocean-100">
{proposal.name || t('chat.flowProposal.title')}
</p>
<p className="mt-1 break-words text-ocean-800/90 dark:text-ocean-200/90">
{t('chat.flowProposal.subtitle')}
</p>
<p className="mt-2 text-xs break-words text-content-secondary">
<span className="font-medium text-content-muted">
{t('chat.flowProposal.triggerLabel')}:
</span>{' '}
{proposal.summary.trigger}
</p>
<div className="mt-2">
<p className="text-xs font-medium text-content-muted">
{t('chat.flowProposal.stepsLabel')}
</p>
{proposal.summary.steps.length > 0 ? (
<ol className="mt-1 max-h-56 list-decimal overflow-y-auto pl-6 text-content-secondary">
{proposal.summary.steps.map((step, i) => (
<li key={i} className="break-words">
<span
data-testid="workflow-proposal-step-kind"
className="mr-1.5 inline-block rounded-full bg-ocean-100 px-1.5 py-0.5 text-[10px] font-medium text-ocean-700 dark:bg-ocean-500/15 dark:text-ocean-300">
{step.kind}
</span>
<span>{step.name}</span>
{step.config_hint ? (
<span
title={step.config_hint}
className="ml-1.5 inline-block max-w-full truncate rounded bg-surface-subtle px-1 py-px align-middle font-mono text-[11px] text-content-muted">
{step.config_hint}
</span>
) : null}
</li>
))}
</ol>
) : (
<p className="mt-1 text-xs text-content-faint">{t('chat.flowProposal.noSteps')}</p>
)}
</div>
{proposal.requireApproval && (
<p className="mt-2 text-xs text-content-faint">
{t('chat.flowProposal.requireApprovalHint')}
</p>
)}
{errorMsg && <p className="mt-2 text-xs text-coral"> {errorMsg}</p>}
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
variant="primary"
size="sm"
data-analytics-id="workflow-proposal-save"
onClick={() => void save()}
disabled={saving}>
{saving ? t('chat.flowProposal.saving') : t('chat.flowProposal.save')}
</Button>
<Button
variant="secondary"
size="sm"
data-analytics-id="workflow-proposal-dismiss"
onClick={dismiss}
disabled={saving}>
{t('chat.flowProposal.dismiss')}
</Button>
</div>
</div>
</div>
</div>
);
};
export default WorkflowProposalCard;
+10
View File
@@ -3019,6 +3019,16 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'العميل يريد القيام بعمل يحتاج إلى موافقتك',
'chat.approval.title': 'الموافقة المطلوبة',
'chat.approval.tool': 'Tool:',
'chat.flowProposal.title': 'اقتراح مسار العمل',
'chat.flowProposal.subtitle': 'راجع هذه الأتمتة قبل حفظها.',
'chat.flowProposal.triggerLabel': 'المُشغّل',
'chat.flowProposal.stepsLabel': 'الخطوات',
'chat.flowProposal.noSteps': 'لا توجد خطوات إضافية.',
'chat.flowProposal.requireApprovalHint': 'سيتطلب كل إجراء صادر موافقتك.',
'chat.flowProposal.save': 'حفظ وتفعيل',
'chat.flowProposal.saving': 'جارٍ الحفظ…',
'chat.flowProposal.dismiss': 'إغلاق',
'chat.flowProposal.error': 'تعذّر حفظ سير العمل. حاول مرة أخرى.',
'channels.authMode.managed_dm': 'قم بتسجيل الدخول باستخدام OpenHuman',
'channels.authMode.oauth': 'OAuth تسجيل الدخول',
'channels.authMode.bot_token': 'استخدم رمز الروبوت الخاص بك',
+11
View File
@@ -3084,6 +3084,17 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'এজেন্ট এমন কাজ করতে চায় যা আপনার অনুমোদন প্রয়োজন.',
'chat.approval.title': 'অনুমোদন প্রয়োজন',
'chat.approval.tool': 'টুল:',
'chat.flowProposal.title': 'ওয়ার্কফ্লো প্রস্তাব',
'chat.flowProposal.subtitle': 'সংরক্ষণ করার আগে এই অটোমেশনটি পর্যালোচনা করুন।',
'chat.flowProposal.triggerLabel': 'ট্রিগার',
'chat.flowProposal.stepsLabel': 'ধাপসমূহ',
'chat.flowProposal.noSteps': 'কোনো অতিরিক্ত ধাপ নেই।',
'chat.flowProposal.requireApprovalHint':
'প্রতিটি বহির্গামী কাজের জন্য আপনার অনুমোদন প্রয়োজন হবে।',
'chat.flowProposal.save': 'সংরক্ষণ ও সক্রিয় করুন',
'chat.flowProposal.saving': 'সংরক্ষণ করা হচ্ছে…',
'chat.flowProposal.dismiss': 'খারিজ করুন',
'chat.flowProposal.error': 'ওয়ার্কফ্লো সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন।',
'channels.authMode.managed_dm': 'OpenHuman দিয়ে লগইন করুন',
'channels.authMode.oauth': 'OAuth সাইন-ইন করুন',
'channels.authMode.bot_token': 'আপনার নিজের বট টোকেন ব্যবহার করুন',
+11
View File
@@ -3161,6 +3161,17 @@ const messages: TranslationMap = {
'Der Agent möchte eine Aktion ausführen, die Ihre Zustimmung erfordert.',
'chat.approval.title': 'Genehmigung erforderlich',
'chat.approval.tool': 'Werkzeug:',
'chat.flowProposal.title': 'Workflow-Vorschlag',
'chat.flowProposal.subtitle': 'Prüfen Sie diese Automatisierung, bevor Sie sie speichern.',
'chat.flowProposal.triggerLabel': 'Auslöser',
'chat.flowProposal.stepsLabel': 'Schritte',
'chat.flowProposal.noSteps': 'Keine weiteren Schritte.',
'chat.flowProposal.requireApprovalHint': 'Jede ausgehende Aktion benötigt Ihre Genehmigung.',
'chat.flowProposal.save': 'Speichern & aktivieren',
'chat.flowProposal.saving': 'Wird gespeichert…',
'chat.flowProposal.dismiss': 'Verwerfen',
'chat.flowProposal.error':
'Der Workflow konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.',
'channels.authMode.managed_dm': 'Mit OpenHuman anmelden',
'channels.authMode.oauth': 'OAuth-Anmeldung',
'channels.authMode.bot_token': 'Eigenen Bot-Token verwenden',
+10
View File
@@ -3544,6 +3544,16 @@ const en: TranslationMap = {
'chat.approval.fallback': 'The agent wants to run an action that needs your approval.',
'chat.approval.title': 'Approval needed',
'chat.approval.tool': 'Tool:',
'chat.flowProposal.title': 'Workflow proposal',
'chat.flowProposal.subtitle': 'Review this automation before saving it.',
'chat.flowProposal.triggerLabel': 'Trigger',
'chat.flowProposal.stepsLabel': 'Steps',
'chat.flowProposal.noSteps': 'No additional steps.',
'chat.flowProposal.requireApprovalHint': 'Every outbound action will need your approval.',
'chat.flowProposal.save': 'Save & enable',
'chat.flowProposal.saving': 'Saving…',
'chat.flowProposal.dismiss': 'Dismiss',
'chat.flowProposal.error': 'Could not save the workflow. Please try again.',
// Auth mode labels
'channels.authMode.managed_dm': 'Login with OpenHuman',
+10
View File
@@ -3139,6 +3139,16 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'El agente quiere ejecutar una acción que necesita su aprobación.',
'chat.approval.title': 'Aprobación necesaria',
'chat.approval.tool': 'Herramienta:',
'chat.flowProposal.title': 'Propuesta de flujo de trabajo',
'chat.flowProposal.subtitle': 'Revisa esta automatización antes de guardarla.',
'chat.flowProposal.triggerLabel': 'Disparador',
'chat.flowProposal.stepsLabel': 'Pasos',
'chat.flowProposal.noSteps': 'No hay pasos adicionales.',
'chat.flowProposal.requireApprovalHint': 'Cada acción saliente necesitará tu aprobación.',
'chat.flowProposal.save': 'Guardar y activar',
'chat.flowProposal.saving': 'Guardando…',
'chat.flowProposal.dismiss': 'Descartar',
'chat.flowProposal.error': 'No se pudo guardar el flujo de trabajo. Inténtalo de nuevo.',
'channels.authMode.managed_dm': 'Iniciar sesión con OpenHuman',
'channels.authMode.oauth': 'OAuth Iniciar sesión',
'channels.authMode.bot_token': 'Utilice su propio token de bot',
+10
View File
@@ -3153,6 +3153,16 @@ const messages: TranslationMap = {
'chat.approval.fallback': "L'agent veut exécuter une action qui nécessite votre approbation.",
'chat.approval.title': 'Approbation requise',
'chat.approval.tool': 'Outil:',
'chat.flowProposal.title': 'Proposition de workflow',
'chat.flowProposal.subtitle': "Vérifiez cette automatisation avant de l'enregistrer.",
'chat.flowProposal.triggerLabel': 'Déclencheur',
'chat.flowProposal.stepsLabel': 'Étapes',
'chat.flowProposal.noSteps': 'Aucune étape supplémentaire.',
'chat.flowProposal.requireApprovalHint': 'Chaque action sortante nécessitera votre approbation.',
'chat.flowProposal.save': 'Enregistrer et activer',
'chat.flowProposal.saving': 'Enregistrement…',
'chat.flowProposal.dismiss': 'Ignorer',
'chat.flowProposal.error': "Impossible d'enregistrer le workflow. Veuillez réessayer.",
'channels.authMode.managed_dm': 'Connectez-vous avec OpenHuman',
'channels.authMode.oauth': 'OAuth Connectez-vous',
'channels.authMode.bot_token': 'Utiliser votre propre jeton de robot',
+10
View File
@@ -3084,6 +3084,16 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'एजेंट अपने अनुमोदन की जरूरत है कि एक कार्रवाई चलाने के लिए चाहता है।',
'chat.approval.title': 'आवश्यक अनुमोदन',
'chat.approval.tool': 'उपकरण:',
'chat.flowProposal.title': 'वर्कफ़्लो प्रस्ताव',
'chat.flowProposal.subtitle': 'सहेजने से पहले इस स्वचालन की समीक्षा करें।',
'chat.flowProposal.triggerLabel': 'ट्रिगर',
'chat.flowProposal.stepsLabel': 'चरण',
'chat.flowProposal.noSteps': 'कोई अतिरिक्त चरण नहीं।',
'chat.flowProposal.requireApprovalHint': 'हर बाहरी कार्रवाई के लिए आपकी स्वीकृति आवश्यक होगी।',
'chat.flowProposal.save': 'सहेजें और सक्षम करें',
'chat.flowProposal.saving': 'सहेजा जा रहा है…',
'chat.flowProposal.dismiss': 'खारिज करें',
'chat.flowProposal.error': 'वर्कफ़्लो सहेजा नहीं जा सका। कृपया फिर से प्रयास करें।',
'channels.authMode.managed_dm': 'OpenHuman से लॉगिन करें',
'channels.authMode.oauth': 'OAuth साइन-इन करें',
'channels.authMode.bot_token': 'अपने स्वयं के बॉट टोकन का उपयोग करें',
+11
View File
@@ -3094,6 +3094,17 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'Agen ingin melakukan tindakan yang membutuhkan persetujuanmu.',
'chat.approval.title': 'Perlu persetujuan',
'chat.approval.tool': 'Alat:',
'chat.flowProposal.title': 'Proposal alur kerja',
'chat.flowProposal.subtitle': 'Tinjau otomatisasi ini sebelum menyimpannya.',
'chat.flowProposal.triggerLabel': 'Pemicu',
'chat.flowProposal.stepsLabel': 'Langkah',
'chat.flowProposal.noSteps': 'Tidak ada langkah tambahan.',
'chat.flowProposal.requireApprovalHint':
'Setiap tindakan keluar akan memerlukan persetujuan Anda.',
'chat.flowProposal.save': 'Simpan & aktifkan',
'chat.flowProposal.saving': 'Menyimpan…',
'chat.flowProposal.dismiss': 'Abaikan',
'chat.flowProposal.error': 'Alur kerja tidak dapat disimpan. Silakan coba lagi.',
'channels.authMode.managed_dm': 'Masuk dengan OpenHuman',
'channels.authMode.oauth': 'OAuth Masuk',
'channels.authMode.bot_token': 'Gunakan Token Bot Anda sendiri',
+10
View File
@@ -3135,6 +3135,16 @@ const messages: TranslationMap = {
"L'agente vuole eseguire un'azione che necessita della tua approvazione.",
'chat.approval.title': 'Approvazione necessaria',
'chat.approval.tool': 'Strumento:',
'chat.flowProposal.title': 'Proposta di workflow',
'chat.flowProposal.subtitle': 'Rivedi questa automazione prima di salvarla.',
'chat.flowProposal.triggerLabel': 'Trigger',
'chat.flowProposal.stepsLabel': 'Passaggi',
'chat.flowProposal.noSteps': 'Nessun passaggio aggiuntivo.',
'chat.flowProposal.requireApprovalHint': 'Ogni azione in uscita richiederà la tua approvazione.',
'chat.flowProposal.save': 'Salva e attiva',
'chat.flowProposal.saving': 'Salvataggio…',
'chat.flowProposal.dismiss': 'Ignora',
'chat.flowProposal.error': 'Impossibile salvare il workflow. Riprova.',
'channels.authMode.managed_dm': 'Accedi con OpenHuman',
'channels.authMode.oauth': 'OAuth Accedi',
'channels.authMode.bot_token': 'Usa il tuo token Bot',
+10
View File
@@ -3053,6 +3053,16 @@ const messages: TranslationMap = {
'chat.approval.fallback': '에이전트가 승인이 필요한 작업을 실행하려고 합니다.',
'chat.approval.title': '승인 필요',
'chat.approval.tool': '도구:',
'chat.flowProposal.title': '워크플로 제안',
'chat.flowProposal.subtitle': '저장하기 전에 이 자동화를 검토하세요.',
'chat.flowProposal.triggerLabel': '트리거',
'chat.flowProposal.stepsLabel': '단계',
'chat.flowProposal.noSteps': '추가 단계가 없습니다.',
'chat.flowProposal.requireApprovalHint': '모든 외부 작업에는 승인이 필요합니다.',
'chat.flowProposal.save': '저장 및 활성화',
'chat.flowProposal.saving': '저장 중…',
'chat.flowProposal.dismiss': '닫기',
'chat.flowProposal.error': '워크플로를 저장할 수 없습니다. 다시 시도하세요.',
'channels.authMode.managed_dm': 'OpenHuman로 로그인',
'channels.authMode.oauth': 'OAuth 로그인',
'channels.authMode.bot_token': '자체 봇 토큰 사용',
+10
View File
@@ -3119,6 +3119,16 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'Agent chce wykonać akcję wymagającą Twojej zgody.',
'chat.approval.title': 'Wymagana zgoda',
'chat.approval.tool': 'Narzędzie:',
'chat.flowProposal.title': 'Propozycja przepływu pracy',
'chat.flowProposal.subtitle': 'Sprawdź tę automatyzację przed zapisaniem.',
'chat.flowProposal.triggerLabel': 'Wyzwalacz',
'chat.flowProposal.stepsLabel': 'Kroki',
'chat.flowProposal.noSteps': 'Brak dodatkowych kroków.',
'chat.flowProposal.requireApprovalHint': 'Każda wychodząca akcja będzie wymagać Twojej zgody.',
'chat.flowProposal.save': 'Zapisz i włącz',
'chat.flowProposal.saving': 'Zapisywanie…',
'chat.flowProposal.dismiss': 'Odrzuć',
'chat.flowProposal.error': 'Nie udało się zapisać przepływu pracy. Spróbuj ponownie.',
'channels.authMode.managed_dm': 'Zaloguj się z OpenHuman',
'channels.authMode.oauth': 'Logowanie OAuth',
'channels.authMode.bot_token': 'Użyj własnego tokena bota',
+10
View File
@@ -3137,6 +3137,16 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'O agente quer executar uma ação que precisa da sua aprovação.',
'chat.approval.title': 'Aprovação necessária',
'chat.approval.tool': 'Ferramenta:',
'chat.flowProposal.title': 'Proposta de fluxo de trabalho',
'chat.flowProposal.subtitle': 'Revise esta automação antes de salvá-la.',
'chat.flowProposal.triggerLabel': 'Gatilho',
'chat.flowProposal.stepsLabel': 'Etapas',
'chat.flowProposal.noSteps': 'Nenhuma etapa adicional.',
'chat.flowProposal.requireApprovalHint': 'Cada ação de saída exigirá sua aprovação.',
'chat.flowProposal.save': 'Salvar e ativar',
'chat.flowProposal.saving': 'Salvando…',
'chat.flowProposal.dismiss': 'Dispensar',
'chat.flowProposal.error': 'Não foi possível salvar o fluxo de trabalho. Tente novamente.',
'channels.authMode.managed_dm': 'Faça login com OpenHuman',
'channels.authMode.oauth': 'OAuth Faça login',
'channels.authMode.bot_token': 'Use seu próprio token de bot',
+10
View File
@@ -3110,6 +3110,16 @@ const messages: TranslationMap = {
'chat.approval.fallback': 'Агент хочет выполнить действие, требующее вашего одобрения.',
'chat.approval.title': 'Требуется одобрение',
'chat.approval.tool': 'Инструмент:',
'chat.flowProposal.title': 'Предложение рабочего процесса',
'chat.flowProposal.subtitle': 'Проверьте эту автоматизацию перед сохранением.',
'chat.flowProposal.triggerLabel': 'Триггер',
'chat.flowProposal.stepsLabel': 'Шаги',
'chat.flowProposal.noSteps': 'Дополнительных шагов нет.',
'chat.flowProposal.requireApprovalHint': 'Каждое исходящее действие потребует вашего одобрения.',
'chat.flowProposal.save': 'Сохранить и включить',
'chat.flowProposal.saving': 'Сохранение…',
'chat.flowProposal.dismiss': 'Скрыть',
'chat.flowProposal.error': 'Не удалось сохранить рабочий процесс. Попробуйте еще раз.',
'channels.authMode.managed_dm': 'Войдите с помощью OpenHuman',
'channels.authMode.oauth': 'OAuth Вход в систему',
'channels.authMode.bot_token': 'Используйте свой собственный токен бота',
+10
View File
@@ -2924,6 +2924,16 @@ const messages: TranslationMap = {
'chat.approval.fallback': '智能体想要运行一项需要你批准的操作。',
'chat.approval.title': '需要批准',
'chat.approval.tool': '工具:',
'chat.flowProposal.title': '工作流建议',
'chat.flowProposal.subtitle': '保存前请先查看此自动化流程。',
'chat.flowProposal.triggerLabel': '触发器',
'chat.flowProposal.stepsLabel': '步骤',
'chat.flowProposal.noSteps': '没有其他步骤。',
'chat.flowProposal.requireApprovalHint': '每个外发操作都需要你的批准。',
'chat.flowProposal.save': '保存并启用',
'chat.flowProposal.saving': '保存中…',
'chat.flowProposal.dismiss': '忽略',
'chat.flowProposal.error': '无法保存该工作流。请重试。',
'channels.authMode.managed_dm': '使用 OpenHuman 登录',
'channels.authMode.oauth': 'OAuth 登录',
'channels.authMode.bot_token': '使用你自己的 Bot Token',
+27
View File
@@ -15,6 +15,7 @@ import IntegrationConnectCard from '../components/chat/IntegrationConnectCard';
import QueuedFollowups from '../components/chat/QueuedFollowups';
import SuperContextToggle from '../components/chat/SuperContextToggle';
import { whenSuperContextWriteSettled } from '../components/chat/superContextWrite';
import WorkflowProposalCard from '../components/chat/WorkflowProposalCard';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
import { settingsNavState } from '../components/settings/modal/settingsOverlay';
@@ -352,6 +353,9 @@ const Conversations = ({
const pendingPlanReviewByThread = useAppSelector(
state => state.chatRuntime.pendingPlanReviewByThread
);
const pendingWorkflowProposalsByThread = useAppSelector(
state => state.chatRuntime.pendingWorkflowProposalsByThread
);
const streamingAssistantByThread = useAppSelector(
state => state.chatRuntime.streamingAssistantByThread
);
@@ -1611,6 +1615,13 @@ const Conversations = ({
const pendingPlanReview = selectedThreadId
? (pendingPlanReviewByThread[selectedThreadId] ?? null)
: null;
// A candidate automation the agent drafted via `propose_workflow` (issue B4),
// awaiting the user's Save/Dismiss decision on `WorkflowProposalCard`. Unlike
// `pendingPlanReview`, the underlying tool call already completed — this
// just controls whether the card is still showing.
const pendingWorkflowProposal = selectedThreadId
? (pendingWorkflowProposalsByThread[selectedThreadId] ?? null)
: null;
const visibleMessages = messages.filter(msg => !msg.extraMetadata?.hidden);
const hasVisibleMessages = visibleMessages.length > 0;
const latestVisibleMessage = visibleMessages[visibleMessages.length - 1] ?? null;
@@ -2882,6 +2893,22 @@ const Conversations = ({
/>
)}
{/* Agent-first Workflow authoring (issue B4): the agent drafted a
candidate automation via `propose_workflow`. The tool only
validates — it never creates the flow — so this card is the ONLY
path from proposal to saved automation via "Save & enable"
(`flows_create`), or the user can Dismiss it outright. */}
{selectedThreadId && pendingWorkflowProposal && (
// Keyed by name so a second proposal in the same thread (before the
// first is resolved) remounts the card and resets its local
// saving/error state, matching the PlanReviewCard pattern above.
<WorkflowProposalCard
key={pendingWorkflowProposal.name}
threadId={selectedThreadId}
proposal={pendingWorkflowProposal}
/>
)}
{selectedThreadId && (
<ThreadTodoStrip
board={selectedTaskBoard}
+68
View File
@@ -51,12 +51,14 @@ import {
setStreamingAssistantForThread,
setTaskBoardForThread,
setToolTimelineForThread,
setWorkflowProposalForThread,
type StreamingAssistantState,
type ToolTimelineEntry,
type ToolTimelineEntryStatus,
upsertArtifactFailedForThread,
upsertArtifactInProgressForThread,
upsertArtifactReadyForThread,
type WorkflowProposal,
} from '../store/chatRuntimeSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { selectSocketStatus } from '../store/socketSelectors';
@@ -251,6 +253,51 @@ function chatTurnUsagePayload(event: ChatDoneEvent): {
};
}
/**
* Parses a completed `propose_workflow` tool call's JSON `output` into a
* `WorkflowProposal` for `WorkflowProposalCard` (issue B4 — agent-first
* Workflow authoring). The tool's `execute()`
* (`src/openhuman/flows/tools.rs`) returns
* `{ type: "workflow_proposal", name, graph, require_approval, summary }` as
* its `ToolResult` body; this maps that wire shape onto the store's camelCase
* `WorkflowProposal`. Returns `null` for anything that fails to parse or
* doesn't match the expected shape — defensive, since a malformed proposal
* must never crash the chat runtime, it should just silently not render a
* card.
*/
function parseWorkflowProposal(output: string): WorkflowProposal | null {
let parsed: unknown;
try {
parsed = JSON.parse(output);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
const obj = parsed as Record<string, unknown>;
if (obj.type !== 'workflow_proposal') return null;
if (typeof obj.name !== 'string' || obj.graph == null) return null;
const summary = (obj.summary ?? {}) as Record<string, unknown>;
const rawSteps = Array.isArray(summary.steps) ? summary.steps : [];
const steps = rawSteps
.filter((s): s is Record<string, unknown> => !!s && typeof s === 'object')
.map(s => ({
kind: typeof s.kind === 'string' ? s.kind : 'unknown',
name: typeof s.name === 'string' ? s.name : '',
config_hint: typeof s.config_hint === 'string' ? s.config_hint : undefined,
}));
return {
name: obj.name,
graph: obj.graph,
// The Rust tool defaults `require_approval` to `true` when the caller
// omits it, so treat anything other than an explicit `false` as `true`
// here too — keeps the client's fallback in lockstep with the server's.
requireApproval: obj.require_approval !== false,
summary: { trigger: typeof summary.trigger === 'string' ? summary.trigger : '', steps },
};
}
export function findPendingDelegationContext(
entries: ToolTimelineEntry[],
round: number
@@ -629,6 +676,27 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
}
}
// Agent-first Workflow authoring (issue B4): a completed
// `propose_workflow` call carries a `workflow_proposal` JSON payload
// in `output` — surface it as a `WorkflowProposalCard` above the
// composer. The tool only validates; only the card's "Save & enable"
// action ever calls `flows_create`, so this dispatch alone can never
// create a flow.
if (event.tool_name === 'propose_workflow' && event.success) {
const proposal = parseWorkflowProposal(event.output);
if (proposal) {
rtLog('propose_workflow proposal parsed', {
thread: event.thread_id,
name: proposal.name,
});
dispatch(setWorkflowProposalForThread({ threadId: event.thread_id, proposal }));
} else {
rtLog('propose_workflow result did not parse as a workflow_proposal', {
thread: event.thread_id,
});
}
}
const current = store.getState().chatRuntime.inferenceStatusByThread[event.thread_id];
if (!current) return;
dispatch(
+35 -2
View File
@@ -1,7 +1,10 @@
/**
* Frontend client for the durable `openhuman.flows_*` run surface (issue B2 /
* B3). Wraps the subset of controllers the B3a approval card and the B3b run
* inspector need:
* B3 / B4). Wraps the subset of controllers the B3a approval card, the B3b
* run inspector, and the B4 agent-proposal card need:
* - `flows_create` — persist a new flow (B4 — only ever called from the
* user's own "Save & enable" click on `WorkflowProposalCard`; the agent's
* `propose_workflow` tool only validates and never reaches this RPC)
* - `flows_resume` — resume a `pending_approval` run past its checkpoint
* - `flows_list_runs` — recent runs for a flow, newest first (B3b)
* - `flows_get_run` — a single run record by id (B3b)
@@ -136,6 +139,35 @@ function unwrapCliEnvelope<T>(payload: unknown): T {
// RPC client.
// ---------------------------------------------------------------------------
/**
* Create (and, by default, enable) a new saved flow via `openhuman.flows_create`
* (issue B4). This is the ONLY path that persists a flow — the agent's
* `propose_workflow` tool (`src/openhuman/flows/tools.rs`) only validates a
* candidate graph and returns a summary; `WorkflowProposalCard`'s "Save &
* enable" button is what calls this function, directly from the client, on
* the user's explicit action. `requireApproval` defaults server-side to
* `false` when omitted, but the B4 proposal flow always passes it explicitly
* (defaulting to `true` on the Rust tool side) so a saved agent-proposed flow
* starts with its outbound-action approval gate on.
*/
export async function createFlow(
name: string,
graph: unknown,
requireApproval?: boolean
): Promise<Flow> {
log('createFlow: request name=%s requireApproval=%s', name, requireApproval ?? 'default');
const response = await callCoreRpc<unknown>({
method: 'openhuman.flows_create',
params:
requireApproval === undefined
? { name, graph }
: { name, graph, require_approval: requireApproval },
});
const flow = unwrapCliEnvelope<Flow>(response);
log('createFlow: response id=%s name=%s enabled=%s', flow.id, flow.name, flow.enabled);
return flow;
}
/**
* Resume a `pending_approval` flow run past its checkpoint via
* `openhuman.flows_resume`. `approvals` should name the node ids from the
@@ -251,6 +283,7 @@ export async function runFlow(id: string, input?: unknown): Promise<FlowResumeRe
}
export const flowsApi = {
createFlow,
resumeFlow,
listFlowRuns,
getFlowRun,
+62
View File
@@ -432,6 +432,40 @@ export interface PendingPlanReview {
steps: string[];
}
/** One step in a `WorkflowProposal`'s summary — a non-trigger node. */
export interface WorkflowProposalStep {
/** tinyflows node kind (e.g. `"agent"`, `"tool_call"`, `"http_request"`). */
kind: string;
/** Human-readable node name. */
name: string;
/** Optional short description of the node's config (e.g. a tool slug, prompt). */
config_hint?: string;
}
/**
* A candidate automation workflow the agent proposed via the `propose_workflow`
* tool (issue B4 — agent-first Workflow authoring). VALIDATED but never
* created — the agent's tool can only validate and summarize a graph; the
* user must click "Save & enable" on `WorkflowProposalCard` to actually
* persist it via `openhuman.flows_create`. Parsed from the `propose_workflow`
* tool call's completed-result JSON (`tool_result` socket event) in
* `ChatRuntimeProvider`.
*/
export interface WorkflowProposal {
/** Proposed flow name. */
name: string;
/** The validated tinyflows WorkflowGraph, ready to hand to `flows_create` as-is. */
graph: unknown;
/** Whether the flow should require approval on every outbound action once saved. */
requireApproval: boolean;
summary: {
/** One-line description of the trigger (e.g. `"schedule: 0 9 * * *"`). */
trigger: string;
/** Ordered non-trigger steps. */
steps: WorkflowProposalStep[];
};
}
/**
* Lifecycle status of a single agent-generated artifact, as projected
* onto the chat runtime per thread.
@@ -529,6 +563,16 @@ interface ChatRuntimeState {
inferenceTurnLifecycleByThread: Record<string, InferenceTurnLifecycle>;
pendingApprovalByThread: Record<string, PendingApproval>;
pendingPlanReviewByThread: Record<string, PendingPlanReview>;
/**
* Thread-scoped candidate workflow proposed by the `propose_workflow` agent
* tool (issue B4), awaiting the user's "Save & enable" / "Dismiss" decision
* on `WorkflowProposalCard`. Unlike `pendingApprovalByThread` /
* `pendingPlanReviewByThread`, this is NOT parked on a server-side gate —
* the underlying tool call already completed; this is purely a
* client-side "should the card render" flag, cleared on Save, Dismiss, or
* thread reset.
*/
pendingWorkflowProposalsByThread: Record<string, WorkflowProposal>;
/**
* Per-thread artifact ledger. Snapshots are upserted on
* `artifact_ready` / `artifact_failed` socket events keyed on
@@ -595,6 +639,7 @@ const initialState: ChatRuntimeState = {
inferenceTurnLifecycleByThread: {},
pendingApprovalByThread: {},
pendingPlanReviewByThread: {},
pendingWorkflowProposalsByThread: {},
artifactsByThread: {},
sessionTokenUsage: emptySessionTokenUsage(),
usageByThread: {},
@@ -1097,6 +1142,15 @@ const chatRuntimeSlice = createSlice({
clearPendingPlanReviewForThread: (state, action: PayloadAction<{ threadId: string }>) => {
delete state.pendingPlanReviewByThread[action.payload.threadId];
},
setWorkflowProposalForThread: (
state,
action: PayloadAction<{ threadId: string; proposal: WorkflowProposal }>
) => {
state.pendingWorkflowProposalsByThread[action.payload.threadId] = action.payload.proposal;
},
clearWorkflowProposalForThread: (state, action: PayloadAction<{ threadId: string }>) => {
delete state.pendingWorkflowProposalsByThread[action.payload.threadId];
},
/**
* Mark a producer-tool call as in-flight so the `ArtifactCard` can
* render a spinner before any ready/failed event arrives. Caller
@@ -1292,6 +1346,7 @@ const chatRuntimeSlice = createSlice({
delete state.inferenceTurnLifecycleByThread[action.payload.threadId];
delete state.pendingApprovalByThread[action.payload.threadId];
delete state.pendingPlanReviewByThread[action.payload.threadId];
delete state.pendingWorkflowProposalsByThread[action.payload.threadId];
delete state.queueStatusByThread[action.payload.threadId];
delete state.queuedFollowupsByThread[action.payload.threadId];
delete state.pendingSendThreadIds[action.payload.threadId];
@@ -1313,6 +1368,7 @@ const chatRuntimeSlice = createSlice({
state.inferenceTurnLifecycleByThread = {};
state.pendingApprovalByThread = {};
state.pendingPlanReviewByThread = {};
state.pendingWorkflowProposalsByThread = {};
state.artifactsByThread = {};
state.queueStatusByThread = {};
state.queuedFollowupsByThread = {};
@@ -1416,6 +1472,10 @@ const chatRuntimeSlice = createSlice({
// Likewise drop any stale parked plan review — its gate future cannot
// survive a rehydrate, so the card must not linger.
delete state.pendingPlanReviewByThread[threadId];
// Same for a workflow proposal (B4) — it's a client-only "should the
// card render" flag with no server-side record, so a rehydrate must
// not resurrect one left over from a previous session.
delete state.pendingWorkflowProposalsByThread[threadId];
if (snapshot.taskBoard) {
state.taskBoardByThread[threadId] = snapshot.taskBoard;
}
@@ -1518,6 +1578,8 @@ export const {
clearPendingApprovalForThread,
setPendingPlanReviewForThread,
clearPendingPlanReviewForThread,
setWorkflowProposalForThread,
clearWorkflowProposalForThread,
upsertArtifactInProgressForThread,
upsertArtifactReadyForThread,
upsertArtifactFailedForThread,
+1
View File
@@ -72,6 +72,7 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = {
audio_generate_and_email_podcast: 'Generating & emailing podcast',
composio_list_connections: 'Viewing your Connections',
agent_prepare_context: 'Preparing context',
propose_workflow: 'Proposing workflow',
};
/**
+1
View File
@@ -11,6 +11,7 @@ pub mod bus;
pub mod ops;
mod schemas;
mod store;
pub mod tools;
mod types;
pub use schemas::{
+8 -1
View File
@@ -26,7 +26,14 @@ const FLOW_RUN_TIMEOUT_SECS: u64 = 600;
/// an older-schema definition to current), deserializes it, and rejects a
/// structurally invalid graph via `tinyflows::validate::validate` — so a bad
/// graph is caught at the door, before it's ever persisted.
fn validate_and_migrate_graph(graph_json: Value) -> Result<WorkflowGraph, String> {
///
/// `pub(crate)` (not private) so `flows::tools::ProposeWorkflowTool` (issue
/// B4 — agent-first workflow authoring) can run a candidate graph through the
/// exact same validate/migrate path `flows_create` uses below, without
/// duplicating it. The tool only calls this — never `flows_create` itself —
/// which is what keeps the "the agent can never create a flow" invariant
/// intact: this function validates and returns, it has no persistence effect.
pub(crate) fn validate_and_migrate_graph(graph_json: Value) -> Result<WorkflowGraph, String> {
let migrated = tinyflows::migrate::migrate(graph_json).map_err(|e| e.to_string())?;
let graph: WorkflowGraph = serde_json::from_value(migrated).map_err(|e| e.to_string())?;
tinyflows::validate::validate(&graph).map_err(|e| e.to_string())?;
+337
View File
@@ -0,0 +1,337 @@
//! Agent-facing tool for the `flows::` domain (issue B4 — agent-first
//! Workflow authoring): [`ProposeWorkflowTool`] ("propose_workflow").
//!
//! The user asks the assistant in chat to build an automation; the agent
//! calls this tool with a candidate `tinyflows::model::WorkflowGraph`. The
//! tool runs the graph through the exact same
//! [`crate::openhuman::flows::ops::validate_and_migrate_graph`] path
//! `flows_create` uses, and returns a `workflow_proposal` summary for the
//! chat UI's `WorkflowProposalCard` — it never persists anything itself.
//!
//! **Human-in-the-loop invariant:** this tool must NEVER call
//! [`crate::openhuman::flows::ops::flows_create`] (or any other persistence
//! path). Only the user's "Save & enable" click in `WorkflowProposalCard`
//! creates the flow, via the `openhuman.flows_create` RPC directly from the
//! client. `permission_level() == PermissionLevel::None` and
//! `external_effect() == false` reflect that this call has no side effect —
//! it is pure validation.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{json, Value};
use tinyflows::model::{Node, NodeKind, WorkflowGraph};
use crate::openhuman::config::Config;
use crate::openhuman::flows::ops::validate_and_migrate_graph;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
/// Max characters kept for a `config_hint` before truncation, so a long
/// prompt/expression doesn't blow up the proposal summary sent to the LLM
/// and rendered in the chat card.
const MAX_CONFIG_HINT_CHARS: usize = 80;
pub struct ProposeWorkflowTool {
config: Arc<Config>,
}
impl ProposeWorkflowTool {
pub fn new(config: Arc<Config>) -> Self {
Self { config }
}
}
#[async_trait]
impl Tool for ProposeWorkflowTool {
fn name(&self) -> &str {
"propose_workflow"
}
fn description(&self) -> &str {
"Propose a candidate automation workflow for the user to review and save. This tool \
ONLY VALIDATES the graph and returns a summary — it NEVER creates or enables the flow; \
the user must click \"Save & enable\" in the UI before anything is persisted or can \
run. Build a tinyflows WorkflowGraph: nodes[] ({id, kind, name, config}) + edges[] \
({from_node, to_node, from_port?, to_port?}; ports default \"main\"). Exactly ONE \
trigger node is required. The 12 node kinds: trigger (config.trigger_kind: manual | \
schedule | webhook | app_event | form | chat_message | evaluation | system | \
execute_by_workflow; schedule needs config.schedule = {kind:\"cron\",expr,tz?} | \
{kind:\"at\",at} | {kind:\"every\",every_ms}; app_event needs config.toolkit + \
config.trigger_slug), agent (config.prompt), tool_call (config.slug REQUIRED + \
config.args), http_request (config.method/url, optional headers/body), code \
(config.language: \"javascript\"|\"python\" + config.source), condition (config.field; \
routes ports \"true\"/\"false\"), switch (config.expression or config.field; routes to \
the matching case port, or \"default\"), transform (config.set: {key: \"=expr\"} \
merged onto each item), split_out (config.path to an array field; fans out one item per \
element), merge (fan-in passthrough, no config), output_parser (passthrough today; no \
config required), sub_workflow (config.workflow: an embedded child WorkflowGraph). If \
validation fails, fix the graph and call this tool again."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Human-readable name for the proposed flow."
},
"graph": {
"type": "object",
"description": "A tinyflows WorkflowGraph: { name?, nodes: [...], edges: [...] }. See the tool description for node kinds and their config shapes.",
"properties": {
"nodes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string", "description": "Unique id within the graph." },
"kind": {
"type": "string",
"enum": [
"trigger", "agent", "tool_call", "http_request",
"code", "condition", "switch", "merge", "split_out",
"transform", "output_parser", "sub_workflow"
]
},
"name": { "type": "string", "description": "Human-readable node name." },
"config": { "description": "Kind-specific configuration; see tool description." }
},
"required": ["id", "kind", "name"]
}
},
"edges": {
"type": "array",
"items": {
"type": "object",
"properties": {
"from_node": { "type": "string" },
"to_node": { "type": "string" },
"from_port": { "type": "string", "description": "Defaults to \"main\"." },
"to_port": { "type": "string", "description": "Defaults to \"main\"." }
},
"required": ["from_node", "to_node"]
}
}
},
"required": ["nodes", "edges"]
},
"require_approval": {
"type": "boolean",
"description": "Force a human-approval gate on every outbound tool/HTTP action this flow takes once saved. Defaults to true for agent-proposed flows."
}
},
"required": ["name", "graph"]
})
}
fn permission_level(&self) -> PermissionLevel {
// Pure validation with no side effect — see module doc.
PermissionLevel::None
}
fn external_effect(&self) -> bool {
// Never persists or executes anything; only `flows_create` (invoked
// from the client by the user's own "Save & enable" click) does.
false
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let name = match args.get("name").and_then(Value::as_str).map(str::trim) {
Some(name) if !name.is_empty() => name.to_string(),
_ => return Ok(ToolResult::error("Missing 'name' parameter".to_string())),
};
let graph_json = match args.get("graph") {
Some(v) if !v.is_null() => v.clone(),
_ => return Ok(ToolResult::error("Missing 'graph' parameter".to_string())),
};
let require_approval = args
.get("require_approval")
.and_then(Value::as_bool)
.unwrap_or(true);
tracing::debug!(
target: "flows",
%name,
require_approval,
workspace = %self.config.workspace_dir.display(),
"[flows] propose_workflow: validating candidate graph"
);
let graph = match validate_and_migrate_graph(graph_json) {
Ok(graph) => graph,
Err(e) => {
tracing::debug!(
target: "flows",
%name,
error = %e,
"[flows] propose_workflow: validation failed"
);
return Ok(ToolResult::error(format!(
"Workflow graph is invalid: {e}. Fix the graph and call propose_workflow \
again."
)));
}
};
let summary = build_summary(&graph);
let graph_value = serde_json::to_value(&graph)?;
tracing::info!(
target: "flows",
%name,
node_count = graph.nodes.len(),
require_approval,
"[flows] propose_workflow: proposal ready for user review"
);
Ok(ToolResult::success(serde_json::to_string_pretty(&json!({
"type": "workflow_proposal",
"name": name,
"graph": graph_value,
"require_approval": require_approval,
"summary": summary,
}))?))
}
}
/// Builds the `{ trigger, steps }` summary surfaced to both the LLM (in the
/// tool result) and the chat UI's `WorkflowProposalCard`.
fn build_summary(graph: &WorkflowGraph) -> Value {
let trigger = graph
.trigger()
.map(describe_trigger)
.unwrap_or_else(|| "no trigger".to_string());
let steps: Vec<Value> = graph
.nodes
.iter()
.filter(|n| n.kind != NodeKind::Trigger)
.map(|n| {
let mut step = json!({
"kind": node_kind_str(&n.kind),
"name": n.name,
});
if let Some(hint) = config_hint(n) {
step["config_hint"] = json!(hint);
}
step
})
.collect();
json!({ "trigger": trigger, "steps": steps })
}
/// The `snake_case` wire string for a [`NodeKind`] (its `Serialize` impl),
/// for the summary/step JSON. Falls back to `"unknown"` only if serializing
/// ever somehow fails — `NodeKind`'s derive is infallible in practice.
fn node_kind_str(kind: &NodeKind) -> String {
serde_json::to_value(kind)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| "unknown".to_string())
}
/// One-line human description of a trigger node, for the summary's
/// `"trigger"` field — e.g. `"schedule: 0 9 * * *"`, `"app event:
/// gmail/GMAIL_NEW_GMAIL_MESSAGE"`, `"manual"`.
fn describe_trigger(node: &Node) -> String {
let trigger_kind = node
.config
.get("trigger_kind")
.and_then(Value::as_str)
.unwrap_or("manual");
match trigger_kind {
"schedule" => {
let schedule = node.config.get("schedule");
if let Some(expr) = schedule.and_then(|s| s.get("expr")).and_then(Value::as_str) {
format!("schedule: {expr}")
} else if let Some(ms) = schedule
.and_then(|s| s.get("every_ms"))
.and_then(Value::as_u64)
{
format!("schedule: every {ms}ms")
} else if let Some(at) = schedule.and_then(|s| s.get("at")).and_then(Value::as_str) {
format!("schedule: once at {at}")
} else {
"schedule (unspecified)".to_string()
}
}
"app_event" => {
let toolkit = node
.config
.get("toolkit")
.and_then(Value::as_str)
.unwrap_or("?");
let slug = node
.config
.get("trigger_slug")
.and_then(Value::as_str)
.unwrap_or("?");
format!("app event: {toolkit}/{slug}")
}
other => other.to_string(),
}
}
/// Short, human-readable hint for a non-trigger node's config, for the
/// step's optional `"config_hint"` field. `None` when the kind has nothing
/// worth surfacing (e.g. `merge`, `output_parser`).
fn config_hint(node: &Node) -> Option<String> {
let cfg = &node.config;
match &node.kind {
NodeKind::Agent => cfg.get("prompt").and_then(Value::as_str).map(truncate_hint),
NodeKind::ToolCall => cfg.get("slug").and_then(Value::as_str).map(str::to_string),
NodeKind::HttpRequest => {
let method = cfg.get("method").and_then(Value::as_str).unwrap_or("GET");
let url = cfg.get("url").and_then(Value::as_str).unwrap_or("?");
Some(truncate_hint(&format!("{method} {url}")))
}
NodeKind::Code => cfg
.get("language")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| Some("javascript".to_string())),
NodeKind::Condition => cfg
.get("field")
.and_then(Value::as_str)
.map(|f| format!("field: {f}")),
NodeKind::Switch => cfg
.get("expression")
.and_then(Value::as_str)
.or_else(|| cfg.get("field").and_then(Value::as_str))
.map(truncate_hint),
NodeKind::Transform => cfg.get("set").and_then(Value::as_object).map(|set| {
let keys: Vec<&str> = set.keys().map(String::as_str).collect();
truncate_hint(&format!("sets: {}", keys.join(", ")))
}),
NodeKind::SplitOut => cfg
.get("path")
.and_then(Value::as_str)
.map(|p| format!("path: {p}")),
NodeKind::SubWorkflow => Some("embedded sub-workflow".to_string()),
NodeKind::Merge | NodeKind::OutputParser | NodeKind::Trigger => None,
}
}
/// Truncates a hint string to [`MAX_CONFIG_HINT_CHARS`], appending an
/// ellipsis when it was cut — mirrors
/// `crate::openhuman::tools::traits::render_context_value`'s truncation
/// behavior for tool-call timeline details.
fn truncate_hint(s: &str) -> String {
if s.chars().count() <= MAX_CONFIG_HINT_CHARS {
return s.to_string();
}
let truncated: String = s
.chars()
.take(MAX_CONFIG_HINT_CHARS.saturating_sub(1))
.collect();
format!("{truncated}")
}
#[cfg(test)]
#[path = "tools_tests.rs"]
mod tests;
+264
View File
@@ -0,0 +1,264 @@
use super::*;
use crate::openhuman::config::Config;
use serde_json::json;
use tempfile::TempDir;
fn test_config(tmp: &TempDir) -> Arc<Config> {
let config = Config {
workspace_dir: tmp.path().join("workspace"),
action_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap();
Arc::new(config)
}
fn valid_graph() -> Value {
json!({
"nodes": [
{
"id": "t",
"kind": "trigger",
"name": "Every morning",
"config": { "trigger_kind": "schedule", "schedule": { "kind": "cron", "expr": "0 9 * * *" } }
},
{
"id": "a",
"kind": "agent",
"name": "Summarize",
"config": { "prompt": "Summarize yesterday's messages" }
},
{
"id": "s",
"kind": "tool_call",
"name": "Post to Slack",
"config": { "slug": "slack.post_message", "args": { "channel": "#general" } }
}
],
"edges": [
{ "from_node": "t", "to_node": "a" },
{ "from_node": "a", "to_node": "s" }
]
})
}
#[tokio::test]
async fn valid_graph_returns_workflow_proposal_success() {
let tmp = TempDir::new().unwrap();
let tool = ProposeWorkflowTool::new(test_config(&tmp));
let result = tool
.execute(json!({ "name": "Daily standup summary", "graph": valid_graph() }))
.await
.unwrap();
assert!(!result.is_error, "{}", result.output());
let parsed: Value = serde_json::from_str(&result.output()).expect("valid JSON output");
assert_eq!(parsed["type"], "workflow_proposal");
assert_eq!(parsed["name"], "Daily standup summary");
assert_eq!(parsed["graph"]["nodes"].as_array().unwrap().len(), 3);
}
#[tokio::test]
async fn no_trigger_graph_is_an_error() {
let tmp = TempDir::new().unwrap();
let tool = ProposeWorkflowTool::new(test_config(&tmp));
let graph_without_trigger = json!({
"nodes": [ { "id": "a", "kind": "output_parser", "name": "A" } ],
"edges": []
});
let result = tool
.execute(json!({ "name": "bad", "graph": graph_without_trigger }))
.await
.unwrap();
assert!(result.is_error);
assert!(
result.output().to_lowercase().contains("trigger"),
"expected a trigger-related validation error, got: {}",
result.output()
);
}
#[tokio::test]
async fn missing_name_is_an_error() {
let tmp = TempDir::new().unwrap();
let tool = ProposeWorkflowTool::new(test_config(&tmp));
let result = tool
.execute(json!({ "graph": valid_graph() }))
.await
.unwrap();
assert!(result.is_error);
assert!(result.output().contains("Missing 'name'"));
}
#[tokio::test]
async fn missing_graph_is_an_error() {
let tmp = TempDir::new().unwrap();
let tool = ProposeWorkflowTool::new(test_config(&tmp));
let result = tool
.execute(json!({ "name": "no graph here" }))
.await
.unwrap();
assert!(result.is_error);
assert!(result.output().contains("Missing 'graph'"));
}
#[tokio::test]
async fn omitted_require_approval_defaults_true_in_result() {
let tmp = TempDir::new().unwrap();
let tool = ProposeWorkflowTool::new(test_config(&tmp));
let result = tool
.execute(json!({ "name": "demo", "graph": valid_graph() }))
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result.output()).unwrap();
assert_eq!(parsed["require_approval"], true);
}
#[tokio::test]
async fn explicit_require_approval_false_is_respected() {
let tmp = TempDir::new().unwrap();
let tool = ProposeWorkflowTool::new(test_config(&tmp));
let result = tool
.execute(json!({ "name": "demo", "graph": valid_graph(), "require_approval": false }))
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result.output()).unwrap();
assert_eq!(parsed["require_approval"], false);
}
#[tokio::test]
async fn summary_step_count_and_kinds_are_correct() {
let tmp = TempDir::new().unwrap();
let tool = ProposeWorkflowTool::new(test_config(&tmp));
let result = tool
.execute(json!({ "name": "demo", "graph": valid_graph() }))
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result.output()).unwrap();
let steps = parsed["summary"]["steps"].as_array().unwrap();
// 3 nodes total, minus the 1 trigger = 2 steps.
assert_eq!(steps.len(), 2);
assert_eq!(steps[0]["kind"], "agent");
assert_eq!(steps[0]["name"], "Summarize");
assert_eq!(steps[0]["config_hint"], "Summarize yesterday's messages");
assert_eq!(steps[1]["kind"], "tool_call");
assert_eq!(steps[1]["name"], "Post to Slack");
assert_eq!(steps[1]["config_hint"], "slack.post_message");
}
#[tokio::test]
async fn summary_trigger_describes_schedule() {
let tmp = TempDir::new().unwrap();
let tool = ProposeWorkflowTool::new(test_config(&tmp));
let result = tool
.execute(json!({ "name": "demo", "graph": valid_graph() }))
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result.output()).unwrap();
assert_eq!(parsed["summary"]["trigger"], "schedule: 0 9 * * *");
}
#[tokio::test]
async fn summary_trigger_describes_manual_default() {
let tmp = TempDir::new().unwrap();
let tool = ProposeWorkflowTool::new(test_config(&tmp));
let graph = json!({
"nodes": [ { "id": "t", "kind": "trigger", "name": "Manual start" } ],
"edges": []
});
let result = tool
.execute(json!({ "name": "demo", "graph": graph }))
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result.output()).unwrap();
assert_eq!(parsed["summary"]["trigger"], "manual");
assert!(parsed["summary"]["steps"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn summary_trigger_describes_app_event() {
let tmp = TempDir::new().unwrap();
let tool = ProposeWorkflowTool::new(test_config(&tmp));
let graph = json!({
"nodes": [
{
"id": "t",
"kind": "trigger",
"name": "On new email",
"config": {
"trigger_kind": "app_event",
"toolkit": "gmail",
"trigger_slug": "GMAIL_NEW_GMAIL_MESSAGE"
}
}
],
"edges": []
});
let result = tool
.execute(json!({ "name": "demo", "graph": graph }))
.await
.unwrap();
let parsed: Value = serde_json::from_str(&result.output()).unwrap();
assert_eq!(
parsed["summary"]["trigger"],
"app event: gmail/GMAIL_NEW_GMAIL_MESSAGE"
);
}
#[test]
fn propose_workflow_never_creates_a_flow() {
// The tool must have no way to persist a flow — the human-in-the-loop
// invariant (issue B4) rests entirely on `external_effect() == false` and
// `permission_level() == None` (no gate would even fire if this ever
// regressed to true, but a saved flow must still only ever be created by
// the user's own `flows_create` click).
let tmp = TempDir::new().unwrap();
let tool = ProposeWorkflowTool::new(test_config(&tmp));
assert_eq!(tool.permission_level(), PermissionLevel::None);
assert!(!tool.external_effect());
}
#[test]
fn tool_name_and_schema_are_stable() {
let tmp = TempDir::new().unwrap();
let tool = ProposeWorkflowTool::new(test_config(&tmp));
assert_eq!(tool.name(), "propose_workflow");
let schema = tool.parameters_schema();
let required = schema["required"].as_array().unwrap();
assert!(required.iter().any(|v| v.as_str() == Some("name")));
assert!(required.iter().any(|v| v.as_str() == Some("graph")));
}
#[test]
fn display_label_humanizes_the_tool_name() {
let tmp = TempDir::new().unwrap();
let tool = ProposeWorkflowTool::new(test_config(&tmp));
assert_eq!(
tool.display_label(&Value::Null).as_deref(),
Some("Propose Workflow")
);
}
+1
View File
@@ -25,6 +25,7 @@ pub use crate::openhuman::credentials::tools::*;
pub use crate::openhuman::cron::tools::*;
pub use crate::openhuman::dashboard::tools::*;
pub use crate::openhuman::doctor::tools::*;
pub use crate::openhuman::flows::tools::*;
pub use crate::openhuman::health::tools::*;
pub use crate::openhuman::integrations::tools::*;
pub use crate::openhuman::learning::tools::*;
+5
View File
@@ -257,6 +257,11 @@ pub fn all_tools_with_runtime(
Box::new(CronUpdateTool::new(config.clone(), security.clone())),
Box::new(CronRunTool::new(config.clone())),
Box::new(CronRunsTool::new(config.clone())),
// Agent-first Workflow authoring (issue B4): validates a candidate
// graph and returns a proposal summary — never creates/enables a
// flow itself. Only the chat UI's WorkflowProposalCard "Save &
// enable" action calls `flows_create`.
Box::new(ProposeWorkflowTool::new(config.clone())),
// Wallet tools — expose wallet operations to the agent tool-call pipeline
// so the crypto sub-agent can prepare transfers, check status, etc.
Box::new(WalletStatusTool::new()),