feat(flows): prompt bar → instant canvas with backend-driven copilot builder (#4578)

This commit is contained in:
Steven Enamakel
2026-07-06 00:37:01 -07:00
committed by GitHub
parent 169379c2fa
commit bcc9537320
70 changed files with 4742 additions and 437 deletions
+1
View File
@@ -5584,6 +5584,7 @@ dependencies = [
"bitcoin",
"block2 0.6.2",
"bs58",
"bytes",
"chacha20poly1305",
"chrono",
"chrono-tz",
@@ -121,7 +121,10 @@ describe('SuggestedWorkflows', () => {
await waitFor(() => expect(hookState.send).toHaveBeenCalledTimes(1));
const arg = hookState.send.mock.calls[0][0];
expect(arg.displayText).toBe('Auto-file receipts');
expect(arg.prompt).toContain('Build a workflow that files receipts.');
// The build_prompt is now the instruction of a structured create request;
// the server renders the agent brief from it.
expect(arg.request.mode).toBe('create');
expect(arg.request.instruction).toBe('Build a workflow that files receipts.');
});
it('marks the suggestion built when the inline proposal is saved', async () => {
@@ -22,7 +22,6 @@ import createDebug from 'debug';
import { useCallback, useEffect, useState } from 'react';
import { useWorkflowBuilderChat } from '../../hooks/useWorkflowBuilderChat';
import { buildCreatePrompt } from '../../lib/flows/workflowBuilderPrompt';
import { useT } from '../../lib/i18n/I18nContext';
import {
discoverWorkflows,
@@ -155,7 +154,7 @@ export default function SuggestedWorkflows() {
setBuildingId(suggestion.id);
await send({
displayText: suggestion.title,
prompt: buildCreatePrompt(suggestion.build_prompt),
request: { mode: 'create', instruction: suggestion.build_prompt },
});
},
[sending, send]
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { WorkflowGraph, WorkflowNode } from '../../lib/flows/types';
import type { WorkflowProposal } from '../../store/chatRuntimeSlice';
import type { ToolTimelineEntry, WorkflowProposal } from '../../store/chatRuntimeSlice';
import WorkflowCopilotPanel from './WorkflowCopilotPanel';
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
@@ -11,6 +11,8 @@ const hookState = vi.hoisted(() => ({
sending: false,
proposal: null as WorkflowProposal | null,
messages: [] as Array<{ id: string; content: string; sender: 'user' | 'agent' }>,
toolTimeline: [] as ToolTimelineEntry[],
liveResponse: '',
error: null as string | null,
send: vi.fn(),
clearProposal: vi.fn(),
@@ -40,6 +42,8 @@ describe('WorkflowCopilotPanel', () => {
hookState.sending = false;
hookState.proposal = null;
hookState.messages = [];
hookState.toolTimeline = [];
hookState.liveResponse = '';
hookState.error = null;
hookState.send = vi.fn().mockResolvedValue(undefined);
hookState.clearProposal = vi.fn();
@@ -65,7 +69,11 @@ describe('WorkflowCopilotPanel', () => {
expect(hookState.send).toHaveBeenCalledTimes(1);
const arg = hookState.send.mock.calls[0][0];
expect(arg.displayText).toBe('add a Slack notification on failure');
expect(arg.prompt).toContain(JSON.stringify(baseGraph));
// The brief is rendered server-side now; the panel sends a structured
// revise request carrying the current graph as context.
expect(arg.request.mode).toBe('revise');
expect(arg.request.instruction).toBe('add a Slack notification on failure');
expect(arg.request.graph).toEqual(baseGraph);
});
it('renders the conversation transcript (user + agent turns)', () => {
@@ -90,6 +98,49 @@ describe('WorkflowCopilotPanel', () => {
expect(screen.queryByTestId('workflow-copilot-empty')).not.toBeInTheDocument();
});
it('renders the shared tool timeline + streaming reply during a builder turn', () => {
hookState.sending = true;
hookState.toolTimeline = [
{ id: 'call-1', name: 'propose_workflow', round: 0, status: 'running' } as ToolTimelineEntry,
];
hookState.liveResponse = 'Drafting your workflow…';
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
// The shared ToolTimelineBlock renders (not the bespoke transcript), and the
// one-shot "thinking" placeholder is suppressed once activity is streaming.
expect(screen.getByTestId('workflow-copilot-timeline')).toBeInTheDocument();
expect(screen.queryByTestId('workflow-copilot-thinking')).not.toBeInTheDocument();
});
it('shows the live reply as a bubble before the first tool call streams', () => {
hookState.sending = true;
hookState.toolTimeline = [];
hookState.liveResponse = 'Thinking about your Slack digest…';
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByTestId('workflow-copilot-streaming')).toHaveTextContent(
'Thinking about your Slack digest…'
);
// No tool timeline yet, and the plain "thinking" line is replaced by the
// streamed text.
expect(screen.queryByTestId('workflow-copilot-timeline')).not.toBeInTheDocument();
expect(screen.queryByTestId('workflow-copilot-thinking')).not.toBeInTheDocument();
});
it('surfaces a new proposal to the host and shows the added/removed diff', () => {
const onProposal = vi.fn();
// proposed drops "b" and adds "c" vs. base [a, b].
@@ -158,7 +209,50 @@ describe('WorkflowCopilotPanel', () => {
);
expect(hookState.send).toHaveBeenCalledTimes(1);
const arg = hookState.send.mock.calls[0][0];
expect(arg.prompt).toContain('run-7');
expect(arg.prompt).toContain('get_flow_run');
expect(arg.request.mode).toBe('repair');
expect(arg.request.runId).toBe('run-7');
expect(arg.request.error).toBe('boom');
expect(arg.request.graph).toEqual(baseGraph);
});
it('auto-sends a build turn once when opened with a prompt-bar build seed', () => {
const { rerender } = render(
<WorkflowCopilotPanel
graph={baseGraph}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
buildSeed={{ description: 'digest my Slack every morning' }}
/>
);
expect(hookState.send).toHaveBeenCalledTimes(1);
const arg = hookState.send.mock.calls[0][0];
// The user's description reads as their own first turn in the transcript,
// while the real prompt injects the blank graph + flow id and asks for the
// full build → dry-run → save arc onto the already-created flow.
expect(arg.displayText).toBe('digest my Slack every morning');
// The user's description reads as their own first turn; the structured
// build request carries the blank graph + flow id so the server's brief
// asks for the full build → dry-run → save arc onto the created flow.
expect(arg.request.mode).toBe('build');
expect(arg.request.instruction).toBe('digest my Slack every morning');
expect(arg.request.graph).toEqual(baseGraph);
expect(arg.request.flowId).toBe('flow-1');
// A re-render (e.g. a graph edit) must not re-fire the seed turn.
rerender(
<WorkflowCopilotPanel
graph={graph(['a', 'b', 'c'])}
flowId="flow-1"
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
buildSeed={{ description: 'digest my Slack every morning' }}
/>
);
expect(hookState.send).toHaveBeenCalledTimes(1);
});
});
+105 -17
View File
@@ -8,10 +8,13 @@
* transcript, surfaces each proposal's node-level diff, and hands Accept/Reject
* up to the host, which applies it to the local draft overlay.
*
* Chat UI parity: the composer is the same {@link ChatComposer} the main chat
* windows use (mic/attachments off here), and turns render as bubbles via the
* shared {@link BubbleMarkdown}, so the copilot reads like a real chat rather
* than a one-shot form.
* Chat UI parity: the copilot reuses the SHARED chat surface end-to-end — the
* same {@link ChatComposer} the main chat windows use (mic/attachments off
* here), turns render as bubbles via the shared {@link BubbleMarkdown}, and the
* builder turn's live tool activity + streaming reply render through the shared
* {@link ToolTimelineBlock} (fed from the runtime's `toolTimelineByThread` /
* `streamingAssistantByThread`, streamed here by Phase B). So the copilot reads
* like a real chat rather than a one-shot form.
*
* Invariant: the copilot only PROPOSES. Accept applies to the UNSAVED local
* draft (no `flows_update`); persistence stays behind the canvas's own Save.
@@ -21,17 +24,28 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useWorkflowBuilderChat } from '../../hooks/useWorkflowBuilderChat';
import { diffGraphs } from '../../lib/flows/graphDiff';
import type { WorkflowGraph } from '../../lib/flows/types';
import {
buildRepairPrompt,
buildRevisePrompt,
type RepairPromptContext,
} from '../../lib/flows/workflowBuilderPrompt';
import { useT } from '../../lib/i18n/I18nContext';
import { BubbleMarkdown } from '../../pages/conversations/components/AgentMessageBubble';
import { ToolTimelineBlock } from '../../pages/conversations/components/ToolTimelineBlock';
import type { WorkflowProposal } from '../../store/chatRuntimeSlice';
import ChatComposer from '../chat/ChatComposer';
import Button from '../ui/Button';
/**
* Context for a repair turn opened from a failed run's inspector ("Fix with
* agent"). Maps directly onto a `repair`-mode builder request.
*/
export interface RepairPromptContext {
/** The failed run id (== thread_id) so the agent can `get_flow_run` it. */
runId: string;
/** The run-level error message, if any. */
error?: string | null;
/** Node ids that failed / are implicated, if known. */
failingNodeIds?: string[];
/** The flow's current graph, injected so the fix builds on the real draft. */
graph: WorkflowGraph;
}
interface Props {
/** The current draft graph, injected as context for each revise turn. */
graph: WorkflowGraph;
@@ -57,6 +71,12 @@ interface Props {
* repair turn once on mount so the copilot opens already diagnosing.
*/
repairSeed?: RepairPromptContext | null;
/**
* Optional build seed (from the Flows prompt bar's instant-create path) —
* auto-sends the user's workflow description once on mount so the copilot
* opens already building it against the just-created blank flow.
*/
buildSeed?: { description: string } | null;
/**
* The workflow's persisted copilot thread id (from the per-flow cache), so
* reopening the panel resumes the same conversation instead of starting fresh.
@@ -74,12 +94,22 @@ export default function WorkflowCopilotPanel({
onReject,
onClose,
repairSeed = null,
buildSeed = null,
seedThreadId = null,
onThreadIdChange,
}: Props) {
const { t } = useT();
const { threadId, sending, proposal, messages, error, send, clearProposal } =
useWorkflowBuilderChat(seedThreadId);
const {
threadId,
sending,
proposal,
messages,
toolTimeline,
liveResponse,
error,
send,
clearProposal,
} = useWorkflowBuilderChat(seedThreadId);
const [text, setText] = useState('');
// Report the (lazily-created) thread id up so the host persists it per flow —
@@ -110,22 +140,53 @@ export default function WorkflowCopilotPanel({
repairSentRef.current = true;
void send({
displayText: t('flows.copilot.repairDisplay'),
prompt: buildRepairPrompt(repairSeed),
request: {
mode: 'repair',
instruction: '',
graph: repairSeed.graph,
runId: repairSeed.runId,
error: repairSeed.error ?? null,
failingNodeIds: repairSeed.failingNodeIds ?? [],
},
});
}, [repairSeed, send, t]);
// Keep the transcript pinned to the newest message / thinking indicator.
// Auto-send the build turn once when opened from the prompt bar's
// instant-create path: the user's description becomes the first user turn on
// this thread, and the prompt asks for the full build → dry-run → save arc
// against the just-created flow (its proposal still lands as the usual
// Accept/Reject diff preview). Falls back to a propose-only revise turn if
// the flow id is somehow missing (a draft canvas has nothing to save onto).
const buildSentRef = useRef(false);
useEffect(() => {
if (!buildSeed || buildSentRef.current) return;
buildSentRef.current = true;
void send({
displayText: buildSeed.description,
request: flowId
? { mode: 'build', instruction: buildSeed.description, graph, flowId }
: { mode: 'revise', instruction: buildSeed.description, graph, flowId },
});
// `graph`/`flowId` are read once for the seed turn — later edits must not
// re-fire it (guarded by the ref regardless).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [buildSeed, send]);
// Keep the transcript pinned to the newest message / streamed activity.
// `scrollTo` is optional-chained: jsdom (tests) doesn't implement it.
useEffect(() => {
scrollRef.current?.scrollTo?.({ top: scrollRef.current.scrollHeight });
}, [messages, sending, proposal]);
}, [messages, sending, proposal, toolTimeline, liveResponse]);
const submit = useCallback(
async (raw?: string) => {
const trimmed = (raw ?? text).trim();
if (!trimmed || sending) return;
setText('');
await send({ displayText: trimmed, prompt: buildRevisePrompt(trimmed, graph, flowId) });
await send({
displayText: trimmed,
request: { mode: 'revise', instruction: trimmed, graph, flowId },
});
},
[text, sending, send, graph, flowId]
);
@@ -157,7 +218,10 @@ export default function WorkflowCopilotPanel({
}, [onReject, clearProposal]);
const diff = proposal ? diffGraphs(graph, proposal.graph as WorkflowGraph) : null;
const isEmpty = messages.length === 0 && !proposal && !sending && !error;
const hasTimeline = toolTimeline.length > 0;
const hasLiveText = liveResponse.trim().length > 0;
const isEmpty =
messages.length === 0 && !proposal && !sending && !error && !hasTimeline && !hasLiveText;
return (
<aside
@@ -206,7 +270,31 @@ export default function WorkflowCopilotPanel({
)
)}
{sending && (
{/* Live builder activity — the SHARED tool timeline (tool cards + the
streaming reply) the main chat uses, fed from the runtime's streamed
per-thread state. Renders nothing until the turn produces a tool
call. */}
{hasTimeline && (
<div data-testid="workflow-copilot-timeline">
<ToolTimelineBlock
entries={toolTimeline}
liveResponse={hasLiveText ? liveResponse : undefined}
/>
</div>
)}
{/* Pre-tool phase: the reply is streaming but no tool has run yet, so the
timeline is still empty — surface the live text as an agent bubble so
the copilot never looks frozen. */}
{hasLiveText && !hasTimeline && (
<div
className="max-w-[92%] rounded-2xl bg-surface-subtle px-3 py-1.5"
data-testid="workflow-copilot-streaming">
<BubbleMarkdown content={liveResponse} />
</div>
)}
{sending && !hasTimeline && !hasLiveText && (
<p className="text-xs text-content-muted" data-testid="workflow-copilot-thinking">
{t('flows.copilot.thinking')}
</p>
@@ -1,80 +1,72 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { WorkflowProposal } from '../../store/chatRuntimeSlice';
import WorkflowPromptBar from './WorkflowPromptBar';
// Echo i18n keys.
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
// Stub the proposal card so we only assert it renders with the right props.
vi.mock('../chat/WorkflowProposalCard', () => ({
default: ({ threadId, proposal }: { threadId: string; proposal: WorkflowProposal }) => (
<div data-testid="stub-proposal-card">
{threadId}:{proposal.name}
</div>
),
}));
const navigateMock = vi.hoisted(() => vi.fn());
vi.mock('react-router-dom', () => ({ useNavigate: () => navigateMock }));
const hookState = vi.hoisted(() => ({
threadId: null as string | null,
sending: false,
proposal: null as WorkflowProposal | null,
error: null as string | null,
send: vi.fn(),
clearProposal: vi.fn(),
const createFlowMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/api/flowsApi', () => ({
createFlow: (...args: unknown[]) => createFlowMock(...args),
}));
vi.mock('../../hooks/useWorkflowBuilderChat', () => ({ useWorkflowBuilderChat: () => hookState }));
describe('WorkflowPromptBar', () => {
beforeEach(() => {
hookState.threadId = null;
hookState.sending = false;
hookState.proposal = null;
hookState.error = null;
hookState.send = vi.fn().mockResolvedValue(undefined);
hookState.clearProposal = vi.fn();
navigateMock.mockReset();
createFlowMock.mockReset();
createFlowMock.mockResolvedValue({ id: 'flow-1', name: 'digest my Slack every morning' });
});
it('submits a builder turn with a delegation prompt containing the description', async () => {
it('creates a flow named from the prompt and opens its canvas with a build seed', async () => {
render(<WorkflowPromptBar />);
fireEvent.change(screen.getByTestId('workflow-prompt-input'), {
target: { value: 'digest my Slack every morning' },
});
fireEvent.click(screen.getByTestId('workflow-prompt-submit'));
expect(hookState.send).toHaveBeenCalledTimes(1);
const arg = hookState.send.mock.calls[0][0];
expect(arg.displayText).toBe('digest my Slack every morning');
expect(arg.prompt).toContain('digest my Slack every morning');
expect(arg.prompt.toLowerCase()).toContain('workflow builder');
await waitFor(() => expect(navigateMock).toHaveBeenCalledTimes(1));
expect(createFlowMock).toHaveBeenCalledTimes(1);
const [name, graph] = createFlowMock.mock.calls[0];
expect(name).toBe('digest my Slack every morning');
// The created flow is the standard blank graph (single manual trigger).
expect(graph.nodes).toHaveLength(1);
expect(graph.nodes[0].kind).toBe('trigger');
expect(navigateMock).toHaveBeenCalledWith('/flows/flow-1', {
state: { copilotBuild: { description: 'digest my Slack every morning' } },
});
});
it('submits on Enter (Shift+Enter reserved for newlines)', async () => {
render(<WorkflowPromptBar />);
const input = screen.getByTestId('workflow-prompt-input');
fireEvent.change(input, { target: { value: 'ping me daily' } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => expect(createFlowMock).toHaveBeenCalledTimes(1));
});
it('does not submit empty/whitespace input', () => {
render(<WorkflowPromptBar />);
fireEvent.change(screen.getByTestId('workflow-prompt-input'), { target: { value: ' ' } });
fireEvent.click(screen.getByTestId('workflow-prompt-submit'));
expect(hookState.send).not.toHaveBeenCalled();
expect(createFlowMock).not.toHaveBeenCalled();
expect(navigateMock).not.toHaveBeenCalled();
});
it('renders the resulting proposal inline via WorkflowProposalCard', () => {
hookState.threadId = 'builder-thread-1';
hookState.proposal = {
name: 'Morning digest',
graph: { nodes: [], edges: [] },
requireApproval: true,
summary: { trigger: 'schedule', steps: [] },
};
it('shows an error and re-enables the composer when create fails', async () => {
createFlowMock.mockRejectedValue(new Error('boom'));
render(<WorkflowPromptBar />);
const card = screen.getByTestId('stub-proposal-card');
expect(card).toHaveTextContent('builder-thread-1:Morning digest');
});
fireEvent.change(screen.getByTestId('workflow-prompt-input'), {
target: { value: 'digest my Slack every morning' },
});
fireEvent.click(screen.getByTestId('workflow-prompt-submit'));
it('shows the offline hint when the hook reports offline', () => {
hookState.error = 'offline';
render(<WorkflowPromptBar />);
expect(screen.getByTestId('workflow-prompt-error')).toHaveTextContent(
'flows.promptBar.offline'
);
const error = await screen.findByTestId('workflow-prompt-error');
expect(error).toHaveTextContent('flows.promptBar.error');
expect(navigateMock).not.toHaveBeenCalled();
expect(screen.getByTestId('workflow-prompt-input')).not.toBeDisabled();
});
});
+41 -26
View File
@@ -1,22 +1,27 @@
/**
* WorkflowPromptBar (Phase 5c) — the prompt-first authoring surface at the top
* of the Flows page (and its empty-state hero). The user describes a workflow in
* natural language; submitting spawns a `workflow_builder` turn in a DEDICATED
* thread (via {@link useWorkflowBuilderChat}) and renders the returned proposal
* inline with the existing {@link WorkflowProposalCard} ("Open in canvas" +
* "Save & enable").
* WorkflowPromptBar — the prompt-first authoring surface at the top of the
* Flows page (and its empty-state hero). The user describes a workflow in
* natural language; submitting IMMEDIATELY creates a blank flow (named from
* the description) via `flows_create` and navigates into its canvas at
* `/flows/:id` with a `copilotBuild` seed in `location.state`, so the canvas
* opens with the copilot panel already running the build turn. The UI reacts
* instantly instead of holding the user on the list page while the builder
* agent works invisibly on a hidden thread.
*
* Nothing here persists or enables a flow — the composer only asks the agent to
* PROPOSE. Saving stays behind the card's explicit "Save & enable" click.
* The copilot's proposal keeps the usual gates: the agent only PROPOSES; the
* user Accepts the diff and the canvas's explicit Save persists the graph.
*/
import createDebug from 'debug';
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useWorkflowBuilderChat } from '../../hooks/useWorkflowBuilderChat';
import { buildCreatePrompt } from '../../lib/flows/workflowBuilderPrompt';
import { createBlankWorkflowGraph, deriveWorkflowName } from '../../lib/flows/newFlow';
import { useT } from '../../lib/i18n/I18nContext';
import WorkflowProposalCard from '../chat/WorkflowProposalCard';
import { createFlow } from '../../services/api/flowsApi';
import Button from '../ui/Button';
const log = createDebug('app:flows:prompt-bar');
interface Props {
/** Compact (list header) vs. hero (empty-state) presentation. */
variant?: 'compact' | 'hero';
@@ -26,15 +31,31 @@ interface Props {
export default function WorkflowPromptBar({ variant = 'compact', autoFocus = false }: Props) {
const { t } = useT();
const { threadId, sending, proposal, error, send } = useWorkflowBuilderChat();
const navigate = useNavigate();
const [text, setText] = useState('');
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = useCallback(async () => {
const trimmed = text.trim();
if (!trimmed || sending) return;
await send({ displayText: trimmed, prompt: buildCreatePrompt(trimmed) });
setText('');
}, [text, sending, send]);
if (!trimmed || creating) return;
setCreating(true);
setError(null);
const name = deriveWorkflowName(trimmed, t('flows.page.newWorkflow'));
log('submit: creating flow name=%s', name);
try {
const flow = await createFlow(
name,
createBlankWorkflowGraph(name, t('flows.nodeKind.trigger'))
);
log('submit: created id=%s — opening canvas with build seed', flow.id);
navigate(`/flows/${flow.id}`, { state: { copilotBuild: { description: trimmed } } });
} catch (err) {
log('submit: create failed err=%o', err);
setError(t('flows.promptBar.error'));
setCreating(false);
}
}, [text, creating, navigate, t]);
const onKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -75,7 +96,7 @@ export default function WorkflowPromptBar({ variant = 'compact', autoFocus = fal
onKeyDown={onKeyDown}
rows={isHero ? 2 : 1}
autoFocus={autoFocus}
disabled={sending}
disabled={creating}
placeholder={t('flows.promptBar.placeholder')}
className="min-h-[38px] flex-1 resize-none rounded-lg border border-line bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint focus:border-ocean-400 focus:outline-none disabled:opacity-60"
/>
@@ -84,23 +105,17 @@ export default function WorkflowPromptBar({ variant = 'compact', autoFocus = fal
variant="primary"
size="sm"
data-testid="workflow-prompt-submit"
disabled={sending || text.trim().length === 0}
disabled={creating || text.trim().length === 0}
onClick={() => void submit()}>
{sending ? t('flows.promptBar.thinking') : t('flows.promptBar.submit')}
{creating ? t('flows.promptBar.thinking') : t('flows.promptBar.submit')}
</Button>
</div>
{error && (
<p className="mt-2 text-xs text-coral" data-testid="workflow-prompt-error">
{error === 'offline' ? t('flows.promptBar.offline') : t('flows.promptBar.error')}
{error}
</p>
)}
{threadId && proposal && (
<div className="mt-3" data-testid="workflow-prompt-proposal">
<WorkflowProposalCard threadId={threadId} proposal={proposal} />
</div>
)}
</section>
);
}
@@ -0,0 +1,176 @@
/**
* AgentNodeInspector (Phase E) — the canvas-side controls for an `agent` node's
* two harness-facing knobs, surfaced inside the node-config drawer's
* {@link AgentForm}:
*
* - `agent_ref` — which REGISTERED agent runs this node. Phase A routes an
* agent node whose `config.agent_ref` names a harness `AgentDefinition`
* through the FULL agent tool loop (that definition's ToolScope / sandbox /
* max-iterations govern the turn); leaving it blank runs the bare
* persona-shaped completion. The options come from the same
* `openhuman.agent_registry_list` RPC the Settings → Agents panel uses.
* `agent_ref` is trusted config (never model output), so this picker is the
* only way it's set from the UI.
* - `model` — a MANAGED capability tier (`reasoning-v1` ≈ Opus-class,
* `chat-v1` ≈ Sonnet-class, `agentic-v1`, `burst-v1`) the workspace resolves
* to a concrete model, with a free-form escape hatch for a raw BYOK model id.
* Matches the bare tier slugs Phase A's `OpenHumanAgentRunner` resolves and
* the Opus+Sonnet demo template (Phase C) hard-codes.
*
* Presentational + controlled: every edit calls `onChange` with a shallow-merge
* config patch, exactly like the other node-config field groups. The agent list
* is fetched once on mount; a fetch failure degrades to the inherit + custom
* options only (the picker never blocks editing).
*/
import createDebug from 'debug';
import { useEffect, useId, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { agentRegistryApi, type AgentRegistryEntry } from '../../../services/api/agentRegistryApi';
import { configString, SelectField } from './nodeConfig/nodeConfigFields';
const log = createDebug('app:flows:canvas:agentInspector');
/**
* The managed capability tiers offered for an agent node's `model`. Mirrors the
* Rust `MODEL_*_V1` constants (`src/openhuman/config/schema/types.rs`) and the
* slugs `OpenHumanAgentRunner`/`resolve_model_for_hint` accept as bare tier
* names — so the value written here runs unchanged in the flow engine.
*/
export const AGENT_MANAGED_TIERS = ['reasoning-v1', 'chat-v1', 'agentic-v1', 'burst-v1'] as const;
/** Sentinel select value for "type a raw model id" — never persisted. */
const CUSTOM_MODEL = '__custom__';
export interface AgentNodeInspectorProps {
/** The agent node's controlled config object. */
config: Record<string, unknown>;
/** Shallow-merge patch into the node's config. */
onChange: (patch: Record<string, unknown>) => void;
}
/**
* The `agent_ref` picker: an "inherit" default plus every registered agent. A
* currently-set ref that isn't in the fetched list (still loading, or points at
* a now-removed agent) is preserved as its own option so the value never
* silently drops.
*/
function AgentRefField({ config, onChange }: AgentNodeInspectorProps) {
const { t } = useT();
const [agents, setAgents] = useState<AgentRegistryEntry[]>([]);
const value = configString(config, 'agent_ref');
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const list = await agentRegistryApi.list(false);
if (cancelled) return;
log('AgentRefField: loaded %d agent(s)', list.length);
setAgents(list);
} catch (err) {
// Non-fatal: keep just the inherit + preserved-value options so the
// drawer still edits. See module doc.
log('AgentRefField: agent_registry_list failed — inherit only: %o', err);
}
})();
return () => {
cancelled = true;
};
}, []);
const options = [
{ value: '', label: t('flows.nodeConfig.agent.agentRefInherit') },
...agents.map(a => ({ value: a.id, label: a.name })),
];
// Preserve an out-of-list ref so it stays selected and visible.
if (value && !options.some(o => o.value === value)) {
options.push({ value, label: value });
}
return (
<SelectField
label={t('flows.nodeConfig.agent.agentRefLabel')}
hint={t('flows.nodeConfig.agent.agentRefHint')}
value={value}
onChange={v => onChange({ agent_ref: v })}
options={options}
testId="node-config-agent-ref"
/>
);
}
/**
* The managed-tier `model` picker with a custom escape hatch. Writes a bare tier
* slug (`reasoning-v1`…), a raw model id, or `''` to inherit onto `config.model`.
*/
function ManagedModelField({ config, onChange }: AgentNodeInspectorProps) {
const { t } = useT();
const id = useId();
const value = configString(config, 'model');
const isKnown = value === '' || (AGENT_MANAGED_TIERS as readonly string[]).includes(value);
const [customMode, setCustomMode] = useState(value !== '' && !isKnown);
const handleSelect = (next: string) => {
if (next === CUSTOM_MODEL) {
setCustomMode(true);
// Entering custom from a tier/inherit starts with an empty raw id.
if (isKnown) onChange({ model: '' });
return;
}
setCustomMode(false);
onChange({ model: next });
};
return (
<div className="space-y-1.5">
<label
htmlFor={id}
className="block text-[11px] font-medium uppercase tracking-wide text-content-faint">
{t('flows.nodeConfig.agent.modelLabel')}
</label>
<p className="text-[11px] text-content-muted">{t('flows.nodeConfig.agent.modelHint')}</p>
<select
id={id}
className="w-full rounded-lg border border-line bg-surface px-2.5 py-1.5 text-sm text-content focus:border-primary-400 focus:outline-none"
value={customMode ? CUSTOM_MODEL : value}
data-testid="node-config-agent-model"
onChange={e => handleSelect(e.target.value)}>
<option value="">{t('flows.nodeConfig.agent.modelInherit')}</option>
<optgroup label={t('flows.nodeConfig.agent.modelManagedTiers')}>
{AGENT_MANAGED_TIERS.map(tier => (
<option key={tier} value={tier}>
{tier}
</option>
))}
</optgroup>
<option value={CUSTOM_MODEL}>{t('flows.nodeConfig.agent.modelCustom')}</option>
</select>
{customMode && (
<input
type="text"
className="w-full rounded-lg border border-line bg-surface px-2.5 py-1.5 font-mono text-sm text-content focus:border-primary-400 focus:outline-none"
value={value}
placeholder={t('flows.nodeConfig.agent.modelCustomPlaceholder')}
aria-label={t('flows.nodeConfig.agent.modelCustomPlaceholder')}
data-testid="node-config-agent-model-custom"
onChange={e => onChange({ model: e.target.value })}
/>
)}
</div>
);
}
/**
* Agent-node inspector: the `agent_ref` picker over `model` tier picker. Sits
* inside {@link AgentForm} below the prompt so the two harness knobs read as one
* group.
*/
export default function AgentNodeInspector({ config, onChange }: AgentNodeInspectorProps) {
return (
<div className="space-y-3" data-testid="agent-node-inspector">
<AgentRefField config={config} onChange={onChange} />
<ManagedModelField config={config} onChange={onChange} />
</div>
);
}
@@ -0,0 +1,78 @@
/**
* Behavior tests for {@link AgentNodeInspector} — the agent-node canvas controls
* (`agent_ref` picker + managed-tier `model` picker). The registry RPC is
* stubbed so the component renders offline; `useT` is mocked to the key itself.
*/
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AgentRegistryEntry } from '../../../../services/api/agentRegistryApi';
import AgentNodeInspector from '../AgentNodeInspector';
vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
const AGENTS: AgentRegistryEntry[] = [
{ id: 'researcher', name: 'Researcher', description: '', source: 'default', enabled: true },
{ id: 'drafter', name: 'Drafter', description: '', source: 'custom', enabled: true },
];
const listMock = vi.hoisted(() => vi.fn());
vi.mock('../../../../services/api/agentRegistryApi', () => ({
agentRegistryApi: { list: listMock },
}));
describe('AgentNodeInspector', () => {
beforeEach(() => {
listMock.mockReset().mockResolvedValue(AGENTS);
});
it('lists registered agents (with an inherit default) and patches agent_ref on select', async () => {
const onChange = vi.fn();
render(<AgentNodeInspector config={{}} onChange={onChange} />);
await screen.findByRole('option', { name: 'Researcher' });
const select = screen.getByTestId('node-config-agent-ref');
// Inherit + the two fetched agents.
expect(within(select).getAllByRole('option')).toHaveLength(3);
fireEvent.change(select, { target: { value: 'researcher' } });
expect(onChange).toHaveBeenLastCalledWith({ agent_ref: 'researcher' });
});
it('offers the managed tiers and patches config.model with the chosen tier', () => {
const onChange = vi.fn();
render(<AgentNodeInspector config={{}} onChange={onChange} />);
const model = screen.getByTestId('node-config-agent-model');
// inherit + 4 managed tiers + custom sentinel = 6 options.
expect(within(model).getAllByRole('option')).toHaveLength(6);
fireEvent.change(model, { target: { value: 'chat-v1' } });
expect(onChange).toHaveBeenLastCalledWith({ model: 'chat-v1' });
});
it('reveals a raw model input under Custom and patches the typed id', () => {
const onChange = vi.fn();
render(<AgentNodeInspector config={{}} onChange={onChange} />);
expect(screen.queryByTestId('node-config-agent-model-custom')).not.toBeInTheDocument();
fireEvent.change(screen.getByTestId('node-config-agent-model'), {
target: { value: '__custom__' },
});
const custom = screen.getByTestId('node-config-agent-model-custom');
fireEvent.change(custom, { target: { value: 'anthropic/claude-x' } });
expect(onChange).toHaveBeenLastCalledWith({ model: 'anthropic/claude-x' });
});
it('opens the model picker in custom mode for a raw (non-tier) model id', () => {
render(<AgentNodeInspector config={{ model: 'gpt-4o-mini' }} onChange={vi.fn()} />);
expect(screen.getByTestId('node-config-agent-model-custom')).toHaveValue('gpt-4o-mini');
});
it('degrades to inherit-only when the registry fetch fails', async () => {
listMock.mockRejectedValue(new Error('offline'));
const onChange = vi.fn();
render(<AgentNodeInspector config={{}} onChange={onChange} />);
// The picker still renders (never blocks editing); only the inherit option
// is present since no agents loaded.
await waitFor(() => expect(listMock).toHaveBeenCalled());
const select = screen.getByTestId('node-config-agent-ref');
expect(within(select).getAllByRole('option')).toHaveLength(1);
});
});
@@ -9,9 +9,22 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { AgentRegistryEntry } from '../../../../../services/api/agentRegistryApi';
import type { FlowConnection } from '../../../../../services/api/flowsApi';
import { NODE_CONFIG_FORMS, type NodeConfigFormProps } from '../nodeConfigForms';
// AgentForm now renders AgentNodeInspector, which fetches the agent registry on
// mount. Stub it so the form renders offline with a deterministic agent list.
const AGENTS: AgentRegistryEntry[] = [
{ id: 'researcher', name: 'Researcher', description: '', source: 'default', enabled: true },
{ id: 'drafter', name: 'Drafter', description: '', source: 'custom', enabled: true },
];
const agentListMock = vi.hoisted(() => vi.fn());
vi.mock('../../../../../services/api/agentRegistryApi', () => ({
agentRegistryApi: { list: agentListMock },
}));
agentListMock.mockResolvedValue(AGENTS);
function renderForm(kind: keyof typeof NODE_CONFIG_FORMS, props: Partial<NodeConfigFormProps>) {
const Form = NODE_CONFIG_FORMS[kind]!;
const onChange = vi.fn();
@@ -77,12 +90,12 @@ describe('TransformForm', () => {
});
describe('AgentForm', () => {
it('offers model hints and patches config.model with the chosen hint', () => {
it('offers managed tiers and patches config.model with the chosen tier', () => {
const { onChange } = renderForm('agent', {});
fireEvent.change(screen.getByTestId('node-config-agent-model'), {
target: { value: 'hint:coding' },
target: { value: 'reasoning-v1' },
});
expect(onChange).toHaveBeenLastCalledWith({ model: 'hint:coding' });
expect(onChange).toHaveBeenLastCalledWith({ model: 'reasoning-v1' });
});
it('reveals a custom model input when Custom is selected', () => {
@@ -101,6 +114,21 @@ describe('AgentForm', () => {
renderForm('agent', { config: { model: 'claude-sonnet-5' } });
expect(screen.getByTestId('node-config-agent-model-custom')).toHaveValue('claude-sonnet-5');
});
it('lists registered agents and patches config.agent_ref on select', async () => {
const { onChange } = renderForm('agent', {});
// The agent list loads from the (mocked) registry after mount.
await screen.findByRole('option', { name: 'Researcher' });
fireEvent.change(screen.getByTestId('node-config-agent-ref'), { target: { value: 'drafter' } });
expect(onChange).toHaveBeenLastCalledWith({ agent_ref: 'drafter' });
});
it('preserves an out-of-list agent_ref as a selectable option', async () => {
renderForm('agent', { config: { agent_ref: 'ghost-agent' } });
await screen.findByRole('option', { name: 'Researcher' });
// A ref that isn't in the fetched list is kept so the value never drops.
expect(screen.getByTestId('node-config-agent-ref')).toHaveValue('ghost-agent');
});
});
describe('TriggerForm', () => {
@@ -21,6 +21,7 @@ import { useEffect, useState } from 'react';
import type { NodeKind } from '../../../../lib/flows/types';
import { useT } from '../../../../lib/i18n/I18nContext';
import type { FlowConnection } from '../../../../services/api/flowsApi';
import AgentNodeInspector from '../AgentNodeInspector';
import {
ComposioActionField,
type ComposioActionSchema,
@@ -36,7 +37,6 @@ import {
ExpressionField,
JsonField,
KeyMapField,
ModelHintField,
SelectField,
TextAreaField,
TextField,
@@ -197,13 +197,10 @@ function AgentForm({ config, onChange, connections, upstreamOptions }: NodeConfi
/>
</div>
)}
<ModelHintField
label={t('flows.nodeConfig.agent.modelLabel')}
hint={t('flows.nodeConfig.agent.modelHint')}
value={configString(config, 'model')}
onChange={v => onChange({ model: v })}
testId="node-config-agent-model"
/>
{/* Harness knobs: which registered agent runs this node (Phase A routes an
`agent_ref` node through the full tool loop) + its managed model tier.
Both write onto the node config via the same shallow-merge patch. */}
<AgentNodeInspector config={config} onChange={onChange} />
<CredentialPickerField
value={configString(config, 'connection_ref')}
onChange={v => onChange({ connection_ref: v })}
+108 -29
View File
@@ -1,55 +1,60 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { BuilderTurnResult } from '../services/api/flowsApi';
import type { WorkflowProposal } from '../store/chatRuntimeSlice';
import { useWorkflowBuilderChat } from './useWorkflowBuilderChat';
const chatSend = vi.hoisted(() => vi.fn());
vi.mock('../services/chatService', () => ({ chatSend }));
// The hook now runs the builder server-side via `openhuman.flows_build`.
const buildWorkflow = vi.hoisted(() => vi.fn());
vi.mock('../services/api/flowsApi', () => ({ buildWorkflow }));
// Socket is always "connected" for these tests (offline is exercised via the
// prompt bar's error rendering).
// Socket is always "connected" for these tests.
vi.mock('../store/socketSelectors', () => ({ selectSocketStatus: () => 'connected' }));
const dispatch = vi.hoisted(() => vi.fn());
const selectorState = vi.hoisted(() => ({
activeThreadIds: {} as Record<string, true>,
proposals: {} as Record<string, WorkflowProposal>,
messagesByThreadId: {} as Record<string, unknown[]>,
toolTimelineByThread: {} as Record<string, unknown[]>,
streamingAssistantByThread: {} as Record<string, { content: string }>,
}));
vi.mock('../store/hooks', () => ({
useAppDispatch: () => dispatch,
useAppSelector: (sel: (s: unknown) => unknown) =>
sel({
thread: {
activeThreadIds: selectorState.activeThreadIds,
messagesByThreadId: selectorState.messagesByThreadId,
thread: { messagesByThreadId: selectorState.messagesByThreadId },
chatRuntime: {
pendingWorkflowProposalsByThread: selectorState.proposals,
toolTimelineByThread: selectorState.toolTimelineByThread,
streamingAssistantByThread: selectorState.streamingAssistantByThread,
},
chatRuntime: { pendingWorkflowProposalsByThread: selectorState.proposals },
}),
}));
// Tag thread/chatRuntime action creators so the dispatch mock can special-case
// the two thunks that need `.unwrap()`.
vi.mock('../store/threadSlice', () => ({
createNewThread: (labels: string[]) => ({ type: 'createNewThread', labels }),
addMessageLocal: (p: unknown) => ({ type: 'addMessageLocal', p }),
markThreadInferenceActive: (id: string) => ({ type: 'markActive', id }),
clearThreadInferenceActive: (id: string) => ({ type: 'clearActive', id }),
}));
vi.mock('../store/chatRuntimeSlice', () => ({
beginInferenceTurn: (p: unknown) => ({ type: 'begin', p }),
clearRuntimeForThread: (p: unknown) => ({ type: 'clearRuntime', p }),
clearWorkflowProposalForThread: (p: unknown) => ({ type: 'clearProposal', p }),
setToolTimelineForThread: (p: unknown) => ({ type: 'timeline', p }),
setWorkflowProposalForThread: (p: unknown) => ({ type: 'setProposal', p }),
}));
const okResult = (over: Partial<BuilderTurnResult> = {}): BuilderTurnResult => ({
proposal: null,
assistantText: 'done',
error: null,
...over,
});
describe('useWorkflowBuilderChat', () => {
beforeEach(() => {
chatSend.mockReset().mockResolvedValue(undefined);
selectorState.activeThreadIds = {};
buildWorkflow.mockReset().mockResolvedValue(okResult());
selectorState.proposals = {};
selectorState.messagesByThreadId = {};
selectorState.toolTimelineByThread = {};
selectorState.streamingAssistantByThread = {};
dispatch.mockReset().mockImplementation((action: { type: string }) => {
if (action.type === 'createNewThread') {
return { unwrap: () => Promise.resolve({ id: 'builder-1' }) };
@@ -61,50 +66,124 @@ describe('useWorkflowBuilderChat', () => {
});
});
it('creates a dedicated thread on first send and dispatches the turn there', async () => {
it('creates a dedicated thread on first send and runs the builder there', async () => {
const { result } = renderHook(() => useWorkflowBuilderChat());
expect(result.current.threadId).toBeNull();
await act(async () => {
await result.current.send({ displayText: 'hi', prompt: 'DELEGATE PROMPT' });
await result.current.send({
displayText: 'hi',
request: { mode: 'create', instruction: 'email me a digest' },
});
});
// A dedicated "workflow-builder" thread was created and the turn sent there.
// A dedicated "workflow-builder" thread was created and the agent run there.
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'createNewThread', labels: ['workflow-builder'] })
);
expect(chatSend).toHaveBeenCalledWith({ threadId: 'builder-1', message: 'DELEGATE PROMPT' });
// The builder turn streams onto the dedicated thread — its id is threaded
// into `flows_build` as the second arg.
expect(buildWorkflow).toHaveBeenCalledWith(
{ mode: 'create', instruction: 'email me a digest' },
'builder-1'
);
await waitFor(() => expect(result.current.threadId).toBe('builder-1'));
});
it('surfaces the proposal the runtime parsed onto the dedicated thread', async () => {
it('surfaces the proposal the builder returned by dispatching it into the store', async () => {
const proposal: WorkflowProposal = {
name: 'Digest',
graph: { nodes: [], edges: [] },
requireApproval: true,
summary: { trigger: 'schedule', steps: [] },
};
selectorState.proposals = { 'builder-1': proposal };
buildWorkflow.mockResolvedValue(okResult({ proposal }));
const { result } = renderHook(() => useWorkflowBuilderChat());
await act(async () => {
await result.current.send({ displayText: 'hi', prompt: 'PROMPT' });
await result.current.send({
displayText: 'hi',
request: { mode: 'create', instruction: 'x' },
});
});
await waitFor(() => expect(result.current.proposal).toEqual(proposal));
// The proposal is written into the shared store slice via setProposal.
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'setProposal', p: { threadId: 'builder-1', proposal } })
);
});
it('appends only the user turn locally — the runtime owns the agent reply', async () => {
buildWorkflow.mockResolvedValue(okResult({ assistantText: 'Here is your workflow.' }));
const { result } = renderHook(() => useWorkflowBuilderChat());
await act(async () => {
await result.current.send({
displayText: 'hi',
request: { mode: 'create', instruction: 'x' },
});
});
const appended = dispatch.mock.calls
.map(([a]) => a as { type: string; p?: { message?: { sender?: string } } })
.filter(a => a.type === 'addMessageLocal');
// The web channel never persists user messages, so the hook appends the
// user turn itself...
expect(appended.some(a => a.p?.message?.sender === 'user')).toBe(true);
// ...but NOT the agent reply — `ChatRuntimeProvider` appends that on the
// streamed `chat_done`, so appending here too would double it.
expect(appended.some(a => a.p?.message?.sender === 'agent')).toBe(false);
});
it('reuses the same dedicated thread across sends (creates it once)', async () => {
const { result } = renderHook(() => useWorkflowBuilderChat());
await act(async () => {
await result.current.send({ displayText: 'one', prompt: 'P1' });
await result.current.send({
displayText: 'one',
request: { mode: 'create', instruction: 'a' },
});
});
await act(async () => {
await result.current.send({ displayText: 'two', prompt: 'P2' });
await result.current.send({
displayText: 'two',
request: { mode: 'revise', instruction: 'b' },
});
});
const createCalls = dispatch.mock.calls.filter(
([a]) => (a as { type: string }).type === 'createNewThread'
);
expect(createCalls).toHaveLength(1);
expect(chatSend).toHaveBeenLastCalledWith({ threadId: 'builder-1', message: 'P2' });
expect(buildWorkflow).toHaveBeenLastCalledWith(
{ mode: 'revise', instruction: 'b' },
'builder-1'
);
});
it('surfaces the streamed tool timeline + live response for the dedicated thread', async () => {
const { result } = renderHook(() => useWorkflowBuilderChat());
await act(async () => {
await result.current.send({
displayText: 'hi',
request: { mode: 'create', instruction: 'x' },
});
});
// Simulate the runtime streaming onto this thread, then re-render.
selectorState.toolTimelineByThread = {
'builder-1': [{ id: 't1', name: 'propose_workflow', round: 0, status: 'running' }],
};
selectorState.streamingAssistantByThread = { 'builder-1': { content: 'drafting…' } };
const { result: result2 } = renderHook(() => useWorkflowBuilderChat('builder-1'));
expect(result2.current.toolTimeline).toHaveLength(1);
expect(result2.current.liveResponse).toBe('drafting…');
});
it('sets an error when the builder run fails without a proposal', async () => {
buildWorkflow.mockResolvedValue(okResult({ error: 'run failed', assistantText: '' }));
const { result } = renderHook(() => useWorkflowBuilderChat());
await act(async () => {
await result.current.send({
displayText: 'hi',
request: { mode: 'create', instruction: 'x' },
});
});
await waitFor(() => expect(result.current.error).toBe('run failed'));
});
});
+99 -50
View File
@@ -1,50 +1,54 @@
/**
* useWorkflowBuilderChat (Phase 5c) — a thin driver around the existing chat
* runtime for the Flows prompt bar and canvas copilot. It owns a DEDICATED
* thread (created lazily on first send) so a workflow-authoring conversation
* never collides with the user's main chat, sends turns phrased to route to the
* `workflow_builder` specialist (see `lib/flows/workflowBuilderPrompt.ts`), and
* exposes the resulting `WorkflowProposal` the global `ChatRuntimeProvider`
* parses onto this thread.
* useWorkflowBuilderChat — drives the Flows prompt bar and canvas copilot by
* running the `workflow_builder` agent server-side. It owns a DEDICATED thread
* (created lazily on first send) so an authoring conversation never collides
* with the user's main chat, sends a STRUCTURED turn request to
* `openhuman.flows_build` (which renders the brief and runs the agent), and
* surfaces the returned `WorkflowProposal` on this thread.
*
* It deliberately does NOT reimplement the chat runtime: the same
* `addMessageLocal` → `chatSend` path and the same `pendingWorkflowProposalsByThread`
* store slice that `Conversations.tsx` uses drive this. The only new concept is
* per-surface thread scoping.
* The builder is now a first-class backend agent (like the Flow Scout): the core
* constructs the prompt and drives the agent to completion. Phase B streams that
* turn onto the copilot's dedicated thread (text / thinking / tool events +
* a terminal `chat_done`), so this hook passes its `threadId` into
* `openhuman.flows_build` and lets the GLOBAL `ChatRuntimeProvider` own the
* transcript: the provider appends the final assistant message on `chat_done`
* and populates `streamingAssistantByThread` / `toolTimelineByThread` /
* `pendingWorkflowProposalsByThread` for this thread as the turn runs. This hook
* only appends the local USER turn (the web channel never persists user
* messages) and reads the streamed state back out; the blocking
* `{proposal, error}` return is a fallback for when streaming isn't wired
* (CLI / tests / a missed socket event).
*
* Invariant: nothing here persists or enables a flow. The proposal is
* validate-only; saving stays behind the explicit `WorkflowProposalCard`
* "Save & enable" click.
* Invariant: `create`/`revise`/`repair` never persist; only a `build` turn (with
* a real flow id) may save onto an existing flow. Nothing here enables a flow.
*/
import createDebug from 'debug';
import { useCallback, useMemo, useState } from 'react';
import { chatSend } from '../services/chatService';
import { type BuilderTurnRequest, buildWorkflow } from '../services/api/flowsApi';
import {
beginInferenceTurn,
clearRuntimeForThread,
clearWorkflowProposalForThread,
setToolTimelineForThread,
setWorkflowProposalForThread,
type ToolTimelineEntry,
type WorkflowProposal,
} from '../store/chatRuntimeSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { selectSocketStatus } from '../store/socketSelectors';
import {
addMessageLocal,
clearThreadInferenceActive,
createNewThread,
markThreadInferenceActive,
} from '../store/threadSlice';
import { addMessageLocal, createNewThread } from '../store/threadSlice';
import type { ThreadMessage } from '../types/thread';
const log = createDebug('app:flows:builder-chat');
/** A single builder turn: what the user sees vs. what the agent receives. */
/** A single builder turn: what the user sees vs. the structured turn request. */
export interface WorkflowBuilderSendParams {
/** Human-readable text shown as the user's message in the thread transcript. */
displayText: string;
/** The full delegation prompt actually sent to the core (may inject graph/context). */
prompt: string;
/**
* The structured builder-turn request. The core renders the agent's brief
* from this and runs `workflow_builder` directly (via `openhuman.flows_build`)
* — the frontend no longer crafts delegate prompt strings.
*/
request: BuilderTurnRequest;
}
export interface UseWorkflowBuilderChat {
@@ -61,6 +65,18 @@ export interface UseWorkflowBuilderChat {
* transcript reads.
*/
messages: ThreadMessage[];
/**
* The dedicated thread's live tool timeline (streamed by `ChatRuntimeProvider`
* as the builder turn runs) — bound straight into the shared
* `ToolTimelineBlock`. Empty when nothing has streamed on this thread.
*/
toolTimeline: ToolTimelineEntry[];
/**
* The builder turn's in-flight assistant text (the shared streaming lane), for
* `ToolTimelineBlock`'s `liveResponse`. Empty string once the turn settles —
* the final answer then lives in `messages`.
*/
liveResponse: string;
/** Last send error (thread create / RPC failure), or `null`. */
error: string | null;
/** Send a builder turn, creating the dedicated thread on first use. */
@@ -70,6 +86,7 @@ export interface UseWorkflowBuilderChat {
}
const EMPTY_MESSAGES: ThreadMessage[] = [];
const EMPTY_TIMELINE: ToolTimelineEntry[] = [];
/**
* @param seedThreadId Optional existing thread to bind to instead of creating a
@@ -83,12 +100,19 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
const [localSending, setLocalSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const activeThreadIds = useAppSelector(state => state.thread.activeThreadIds);
const proposalsByThread = useAppSelector(
state => state.chatRuntime.pendingWorkflowProposalsByThread
);
const messagesByThreadId = useAppSelector(state => state.thread.messagesByThreadId);
const toolTimelineByThread = useAppSelector(state => state.chatRuntime.toolTimelineByThread);
const streamingAssistantByThread = useAppSelector(
state => state.chatRuntime.streamingAssistantByThread
);
// Prefer the runtime's streamed proposal (populated on this thread by
// `ChatRuntimeProvider` as the builder's `propose_workflow`/`revise_workflow`
// tool result lands); the blocking `send` result is only a fallback that
// writes into the same slice.
const proposal = useMemo(
() => (threadId ? (proposalsByThread[threadId] ?? null) : null),
[threadId, proposalsByThread]
@@ -99,12 +123,22 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
[threadId, messagesByThreadId]
);
// "Sending" = we're mid-dispatch OR the runtime still marks the thread active.
const runtimeActive = threadId ? Boolean(activeThreadIds[threadId]) : false;
const sending = localSending || runtimeActive;
const toolTimeline = useMemo(
() => (threadId ? (toolTimelineByThread[threadId] ?? EMPTY_TIMELINE) : EMPTY_TIMELINE),
[threadId, toolTimelineByThread]
);
const liveResponse = useMemo(
() => (threadId ? (streamingAssistantByThread[threadId]?.content ?? '') : ''),
[threadId, streamingAssistantByThread]
);
// The turn is a single request/response RPC (no streaming runtime), so
// "sending" is simply whether that call is in flight.
const sending = localSending;
const send = useCallback(
async ({ displayText, prompt }: WorkflowBuilderSendParams) => {
async ({ displayText, request }: WorkflowBuilderSendParams) => {
if (localSending) {
log('send: ignored — a turn is already dispatching');
return;
@@ -116,11 +150,6 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
}
setLocalSending(true);
setError(null);
// Declared outside the try so the catch block can see a thread created
// during THIS call — `threadId` state doesn't update synchronously
// within the same closure invocation, so a failure after creation (but
// before this call returns) would otherwise see the stale `null` and
// skip cleanup, leaving that new thread's active markers dangling.
let targetThreadId = threadId;
try {
if (!targetThreadId) {
@@ -144,23 +173,33 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
addMessageLocal({ threadId: targetThreadId, message: userMessage })
).unwrap();
dispatch(setToolTimelineForThread({ threadId: targetThreadId, entries: [] }));
dispatch(beginInferenceTurn({ threadId: targetThreadId }));
dispatch(markThreadInferenceActive(targetThreadId));
// Run the workflow_builder agent server-side, streaming its turn onto
// this thread (Phase B): passing `targetThreadId` makes the core emit
// text/thinking/tool events + a terminal `chat_done` keyed by it. The
// GLOBAL `ChatRuntimeProvider` owns that transcript — it appends the
// final assistant message on `chat_done` and fills the streaming/tool
// slices as the turn runs — so this hook must NOT also append the agent
// reply (doing so would double it). We still await the blocking result
// for its `proposal`/`error` fallback.
log('send: running flows_build thread=%s mode=%s', targetThreadId, request.mode);
const result = await buildWorkflow(request, targetThreadId);
log('send: dispatching builder turn thread=%s', targetThreadId);
await chatSend({ threadId: targetThreadId, message: prompt });
// Surface the proposal via the same store slice the streamed path used,
// so `WorkflowProposalCard` / the copilot preview render unchanged. This
// is a fallback: when streaming is wired the runtime already populated
// `pendingWorkflowProposalsByThread` from the tool result; re-writing the
// same value here is idempotent and covers a missed socket event / CLI.
if (result.proposal) {
dispatch(
setWorkflowProposalForThread({ threadId: targetThreadId, proposal: result.proposal })
);
} else if (result.error) {
setError(result.error);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log('send: failed err=%o', err);
setError(msg);
// The runtime never got a turn to end, so release the active markers we
// optimistically set (guarded: targetThreadId is still null only when
// thread creation itself failed, in which case there's nothing to clear).
if (targetThreadId) {
dispatch(clearRuntimeForThread({ threadId: targetThreadId }));
dispatch(clearThreadInferenceActive(targetThreadId));
}
} finally {
setLocalSending(false);
}
@@ -172,5 +211,15 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
if (threadId) dispatch(clearWorkflowProposalForThread({ threadId }));
}, [dispatch, threadId]);
return { threadId, sending, proposal, messages, error, send, clearProposal };
return {
threadId,
sending,
proposal,
messages,
toolTimeline,
liveResponse,
error,
send,
clearProposal,
};
}
+26 -1
View File
@@ -5,7 +5,12 @@
*/
import { describe, expect, it } from 'vitest';
import { BLANK_TRIGGER_NODE_ID, createBlankWorkflowGraph } from './newFlow';
import {
BLANK_TRIGGER_NODE_ID,
createBlankWorkflowGraph,
deriveWorkflowName,
MAX_DERIVED_NAME_LENGTH,
} from './newFlow';
describe('createBlankWorkflowGraph', () => {
it('produces a single manual trigger and no edges', () => {
@@ -29,3 +34,23 @@ describe('createBlankWorkflowGraph', () => {
expect(graph.nodes.filter(n => n.kind === 'trigger')).toHaveLength(1);
});
});
describe('deriveWorkflowName', () => {
it('uses the first line, collapsing whitespace', () => {
expect(deriveWorkflowName(' digest my Slack \nand more detail', 'fallback')).toBe(
'digest my Slack'
);
});
it('truncates long descriptions with an ellipsis', () => {
const long = 'a'.repeat(2 * MAX_DERIVED_NAME_LENGTH);
const name = deriveWorkflowName(long, 'fallback');
expect(name.length).toBeLessThanOrEqual(MAX_DERIVED_NAME_LENGTH);
expect(name.endsWith('…')).toBe(true);
});
it('falls back when the description is blank', () => {
expect(deriveWorkflowName(' \n whatever', 'New workflow')).toBe('New workflow');
expect(deriveWorkflowName('', 'New workflow')).toBe('New workflow');
});
});
+17
View File
@@ -18,6 +18,23 @@ export const BLANK_TRIGGER_NODE_ID = 'trigger';
* and the graph's own `name`; `triggerName` is the human label shown on the
* starter node in the canvas.
*/
/** Longest flow name we derive from a free-text prompt before truncating. */
export const MAX_DERIVED_NAME_LENGTH = 60;
/**
* Derive a human-readable flow name from a free-text workflow description
* (the prompt-bar's instant-create path names the flow before the builder
* agent has proposed anything). First line only, whitespace-collapsed, and
* truncated with an ellipsis past {@link MAX_DERIVED_NAME_LENGTH}. Falls back
* to `fallback` (the localized "New workflow") when the description is blank.
*/
export function deriveWorkflowName(description: string, fallback: string): string {
const firstLine = (description.split('\n', 1)[0] ?? '').replace(/\s+/g, ' ').trim();
if (!firstLine) return fallback;
if (firstLine.length <= MAX_DERIVED_NAME_LENGTH) return firstLine;
return `${firstLine.slice(0, MAX_DERIVED_NAME_LENGTH - 1).trimEnd()}`;
}
export function createBlankWorkflowGraph(name: string, triggerName: string): WorkflowGraph {
return {
schema_version: 1,
+2
View File
@@ -24,6 +24,7 @@ import appEventRoute from './app-event-route.json';
import askAgent from './ask-agent.json';
import dailyDigest from './daily-digest.json';
import httpFetchParse from './http-fetch-parse.json';
import opusSonnetBrief from './opus-sonnet-brief.json';
import scheduledScrape from './scheduled-scrape.json';
import webhookTriage from './webhook-triage.json';
@@ -57,6 +58,7 @@ export const FLOW_TEMPLATES: FlowTemplate[] = [
{ id: 'app-event-route', category: 'triggered', graph: appEventRoute as WorkflowGraph },
{ id: 'http-fetch-parse', category: 'onDemand', graph: httpFetchParse as WorkflowGraph },
{ id: 'ask-agent', category: 'onDemand', graph: askAgent as WorkflowGraph },
{ id: 'opus-sonnet-brief', category: 'onDemand', graph: opusSonnetBrief as WorkflowGraph },
];
/** i18n key for a template's display name (`flows.templates.<id>.name`). */
@@ -0,0 +1,62 @@
{
"schema_version": 1,
"name": "Research brief (Opus plans, Sonnet drafts)",
"nodes": [
{
"id": "trigger",
"kind": "trigger",
"name": "Run manually with a topic",
"config": { "trigger_kind": "manual" },
"ports": [],
"position": { "x": 0, "y": 0 }
},
{
"id": "planner",
"kind": "agent",
"name": "Plan the brief (reasoning tier)",
"config": {
"model": "reasoning-v1",
"prompt": "=\"You are a research lead. Draft a concise research plan (3-5 steps) and pick one distinctive angle for a brief on: \" + (.run.trigger.topic // \"the requested topic\")",
"output_parser": {
"schema": {
"type": "object",
"required": ["plan", "angle"],
"properties": { "plan": { "type": "string" }, "angle": { "type": "string" } }
}
}
},
"ports": [],
"position": { "x": 0, "y": 160 }
},
{
"id": "drafter",
"kind": "agent",
"name": "Draft the brief (chat tier)",
"config": {
"model": "chat-v1",
"prompt": "=\"Using the plan and angle below, write a polished research brief (~300 words).\\n\\nPlan:\\n\" + (.nodes.planner.item.json.plan // \"\") + \"\\n\\nAngle:\\n\" + (.nodes.planner.item.json.angle // \"\")"
},
"ports": [],
"position": { "x": 0, "y": 320 }
},
{
"id": "shape",
"kind": "transform",
"name": "Shape the result",
"config": {
"set": {
"topic": "=run.trigger.topic",
"plan": "=nodes.planner.item.json.plan",
"draft": "=nodes.drafter.item.text"
}
},
"ports": [],
"position": { "x": 0, "y": 480 }
}
],
"edges": [
{ "from_node": "trigger", "from_port": "main", "to_node": "planner", "to_port": "main" },
{ "from_node": "planner", "from_port": "main", "to_node": "drafter", "to_port": "main" },
{ "from_node": "drafter", "from_port": "main", "to_node": "shape", "to_port": "main" }
]
}
@@ -1,52 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { WorkflowGraph } from './types';
import { buildCreatePrompt, buildRepairPrompt, buildRevisePrompt } from './workflowBuilderPrompt';
const graph: WorkflowGraph = {
schema_version: 1,
name: 'g',
nodes: [{ id: 'a', kind: 'trigger', name: 'Start', config: {}, ports: [] }],
edges: [],
};
describe('buildCreatePrompt', () => {
it('includes the description and asks only for a proposal (never persist)', () => {
const p = buildCreatePrompt(' email me new Slack messages ');
expect(p).toContain('email me new Slack messages');
expect(p.toLowerCase()).toContain('workflow builder');
expect(p.toLowerCase()).toContain('do not save');
});
});
describe('buildRevisePrompt', () => {
it('injects the current graph JSON and the instruction', () => {
const p = buildRevisePrompt('add a Slack notification on failure', graph);
expect(p).toContain('add a Slack notification on failure');
expect(p).toContain(JSON.stringify(graph));
expect(p.toLowerCase()).toContain('revise');
});
});
describe('buildRepairPrompt', () => {
it('references the run, error, and failing nodes and injects the graph', () => {
const p = buildRepairPrompt({
runId: 'run-9',
error: 'HTTP 500 from webhook',
failingNodeIds: ['n2'],
graph,
});
expect(p).toContain('run-9');
expect(p).toContain('get_flow_run');
expect(p).toContain('HTTP 500 from webhook');
expect(p).toContain('n2');
expect(p).toContain(JSON.stringify(graph));
});
it('omits the error/nodes lines when absent', () => {
const p = buildRepairPrompt({ runId: 'run-9', graph });
expect(p).toContain('run-9');
expect(p).not.toContain('Run error:');
expect(p).not.toContain('Failing step node');
});
});
-121
View File
@@ -1,121 +0,0 @@
/**
* workflowBuilderPrompt (Phase 5c) — builds the natural-language turn text that
* routes a chat turn to the `workflow_builder` specialist agent.
*
* There is no UI affordance to target a named agent for a turn: `chatSend`
* carries only a thread + optional model/behaviour `profile_id`, and the core
* always runs the turn through the orchestrator. The orchestrator's
* `build_workflow` delegation edge routes any "build/automate/when-X-do-Y"
* request to `workflow_builder` (see its `when_to_use` in
* `agent_registry/agents/workflow_builder/agent.toml`). So instead of routing
* directly, we phrase the turn so that delegation fires deterministically and
* the specialist ends its turn by calling `propose_workflow` / `revise_workflow`
* — the runtime then surfaces the returned proposal as a `WorkflowProposalCard`.
*
* Every builder here keeps the "propose, never persist" invariant: the prompts
* ask for a PROPOSAL only. Saving/enabling stays behind the explicit
* `WorkflowProposalCard` "Save & enable" click; nothing here can reach
* `flows_create`/`flows_update`/`set_enabled`.
*/
import type { WorkflowGraph } from './types';
/** A leading directive that reliably trips the `build_workflow` delegation. */
const DELEGATE_DIRECTIVE =
'Use the workflow builder to design a tinyflows automation and return a workflow proposal for me to review. Do not save, enable, or run anything.';
/**
* Revise variant: still "propose, never persist" for saving/enabling, but the
* copilot may run an ALREADY-SAVED flow to test it — only when I ask and after
* confirming with me first (the specialist's own prompt enforces the ask).
*/
const DELEGATE_DIRECTIVE_REVISE =
'Use the workflow builder to revise this tinyflows automation and return the revised proposal. Do not save or enable anything (I save via the UI). You may run_workflow the SAVED flow to test it, but ONLY if I ask and only after you confirm with me first.';
/** Serialize a graph compactly for injection as agent context. */
function serializeGraph(graph: WorkflowGraph): string {
try {
return JSON.stringify(graph);
} catch {
return '{}';
}
}
/**
* First-draft prompt for the Flows prompt bar. `description` is the user's
* free-text ask ("email me a digest of new Slack messages every morning").
*/
export function buildCreatePrompt(description: string): string {
const trimmed = description.trim();
return `${DELEGATE_DIRECTIVE}\n\nBuild a workflow that does this:\n${trimmed}`;
}
/**
* Iterative-refine prompt for the canvas copilot. Injects the CURRENT draft
* graph so the specialist revises it in place (via `revise_workflow`) rather
* than starting over. `instruction` is the user's change request ("add a Slack
* notification on failure", "make the schedule weekdays only").
*/
export function buildRevisePrompt(
instruction: string,
graph: WorkflowGraph,
flowId?: string | null
): string {
const trimmed = instruction.trim();
const lines = [
DELEGATE_DIRECTIVE_REVISE,
'',
'Here is the current workflow draft (tinyflows WorkflowGraph JSON):',
'```json',
serializeGraph(graph),
'```',
];
if (flowId) {
lines.push(
'',
`This workflow is saved with flow id \`${flowId}\` — if I ask you to run/test it, you may run_workflow that id, but confirm with me first.`
);
}
lines.push('', 'Revise it as follows and return the full revised proposal:', trimmed);
return lines.join('\n');
}
/** Context for a repair turn opened from a failed run's inspector. */
export interface RepairPromptContext {
/** The failed run id (== thread_id) so the agent can `get_flow_run` it. */
runId: string;
/** The run-level error message, if any. */
error?: string | null;
/** Node ids that failed / are implicated, if known. */
failingNodeIds?: string[];
/** The flow's current graph, injected so the fix builds on the real draft. */
graph: WorkflowGraph;
}
/**
* Repair prompt for "Fix with agent". Preloads the failing run + step context
* so the specialist reads the run (`get_flow_run`), diagnoses the failure, and
* proposes a corrected graph.
*/
export function buildRepairPrompt(ctx: RepairPromptContext): string {
const parts = [
DELEGATE_DIRECTIVE,
'',
`A run of this workflow failed (run id: ${ctx.runId}). Read the run with get_flow_run, diagnose why it failed, and propose a fix.`,
];
if (ctx.error && ctx.error.trim().length > 0) {
parts.push('', `Run error: ${ctx.error.trim()}`);
}
if (ctx.failingNodeIds && ctx.failingNodeIds.length > 0) {
parts.push('', `Failing step node id(s): ${ctx.failingNodeIds.join(', ')}`);
}
parts.push(
'',
'Here is the current workflow draft (tinyflows WorkflowGraph JSON):',
'```json',
serializeGraph(ctx.graph),
'```',
'',
'Return the full corrected proposal.'
);
return parts.join('\n');
}
+7
View File
@@ -3768,6 +3768,10 @@ const messages: TranslationMap = {
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint': 'اختر مستوى القدرة — سيحدد مساحة العمل النموذج.',
'flows.nodeConfig.agent.modelInherit': 'الافتراضي (موروث)',
'flows.nodeConfig.agent.agentRefLabel': 'الوكيل',
'flows.nodeConfig.agent.agentRefHint': 'شغّل هذه العقدة كوكيل مُسجَّل — تُطبَّق أدواته وضوابطه.',
'flows.nodeConfig.agent.agentRefInherit': 'افتراضي (مُنشئ سير العمل)',
'flows.nodeConfig.agent.modelManagedTiers': 'المستويات المُدارة',
'flows.nodeConfig.agent.modelHints': 'تلميحات النموذج',
'flows.nodeConfig.agent.modelCustom': 'نموذج مخصص…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'مثل gpt-4o-mini',
@@ -3866,6 +3870,9 @@ const messages: TranslationMap = {
'استدعِ نقطة نهاية HTTP عند الطلب وحلّل الاستجابة إلى شكل قابل للاستخدام.',
'flows.templates.ask-agent.name': 'اسأل الوكيل',
'flows.templates.ask-agent.description': 'مشغّل يدوي بسيط يسلّم مهمة إلى وكيل.',
'flows.templates.opus-sonnet-brief.name': 'موجز بحثي (Opus يخطّط، Sonnet يصيغ)',
'flows.templates.opus-sonnet-brief.description':
'وكيل من فئة الاستدلال يخطّط الموجز، ووكيل من فئة الدردشة يصيغه، ثم تُشكَّل النتيجة من أجلك.',
'oauth.button.connecting': 'جارٍ الاتصال...',
'oauth.button.loopbackTimeout':
+9
View File
@@ -3857,6 +3857,11 @@ const messages: TranslationMap = {
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint': 'ক্ষমতার স্তর বেছে নিন — ওয়ার্কস্পেস মডেল নির্ধারণ করবে।',
'flows.nodeConfig.agent.modelInherit': 'ডিফল্ট (উত্তরাধিকার)',
'flows.nodeConfig.agent.agentRefLabel': 'এজেন্ট',
'flows.nodeConfig.agent.agentRefHint':
'এই নোডটি একটি নিবন্ধিত এজেন্ট হিসেবে চালান — এর সরঞ্জাম ও সুরক্ষা প্রযোজ্য।',
'flows.nodeConfig.agent.agentRefInherit': 'ডিফল্ট (ওয়ার্কফ্লো বিল্ডার)',
'flows.nodeConfig.agent.modelManagedTiers': 'পরিচালিত স্তর',
'flows.nodeConfig.agent.modelHints': 'মডেল ইঙ্গিত',
'flows.nodeConfig.agent.modelCustom': 'কাস্টম মডেল…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'যেমন gpt-4o-mini',
@@ -3962,6 +3967,10 @@ const messages: TranslationMap = {
'flows.templates.ask-agent.name': 'এজেন্টকে জিজ্ঞাসা করুন',
'flows.templates.ask-agent.description':
'একটি সরল ম্যানুয়াল ট্রিগার যা একটি কাজ এজেন্টকে হস্তান্তর করে।',
'flows.templates.opus-sonnet-brief.name':
'গবেষণা সারসংক্ষেপ (Opus পরিকল্পনা করে, Sonnet খসড়া করে)',
'flows.templates.opus-sonnet-brief.description':
'একটি রিজনিং-টিয়ার এজেন্ট সারসংক্ষেপ পরিকল্পনা করে, একটি চ্যাট-টিয়ার এজেন্ট খসড়া তৈরি করে, তারপর ফলাফলটি আপনার জন্য সাজানো হয়।',
'oauth.button.connecting': 'সংযোগ হচ্ছে...',
'oauth.button.loopbackTimeout':
+8
View File
@@ -3954,6 +3954,11 @@ const messages: TranslationMap = {
'flows.nodeConfig.agent.modelHint':
'Wähle eine Fähigkeitsstufe — der Workspace löst das Modell auf.',
'flows.nodeConfig.agent.modelInherit': 'Standard (erben)',
'flows.nodeConfig.agent.agentRefLabel': 'Agent',
'flows.nodeConfig.agent.agentRefHint':
'Diesen Knoten als registrierten Agenten ausführen — dessen Tools und Schutzmechanismen gelten.',
'flows.nodeConfig.agent.agentRefInherit': 'Standard (Workflow-Builder)',
'flows.nodeConfig.agent.modelManagedTiers': 'Verwaltete Stufen',
'flows.nodeConfig.agent.modelHints': 'Modellhinweise',
'flows.nodeConfig.agent.modelCustom': 'Benutzerdefiniertes Modell…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'z. B. gpt-4o-mini',
@@ -4061,6 +4066,9 @@ const messages: TranslationMap = {
'flows.templates.ask-agent.name': 'Den Agenten fragen',
'flows.templates.ask-agent.description':
'Ein einfacher manueller Auslöser, der einem Agenten eine Aufgabe übergibt.',
'flows.templates.opus-sonnet-brief.name': 'Recherche-Briefing (Opus plant, Sonnet verfasst)',
'flows.templates.opus-sonnet-brief.description':
'Ein Reasoning-Agent plant das Briefing, ein Chat-Agent verfasst es, dann wird das Ergebnis für dich aufbereitet.',
'oauth.button.connecting': 'Verbinden...',
'oauth.button.loopbackTimeout':
+8
View File
@@ -4570,10 +4570,15 @@ const en: TranslationMap = {
'flows.nodeConfig.http.bodyLabel': 'Body (JSON)',
'flows.nodeConfig.agent.promptLabel': 'Prompt',
'flows.nodeConfig.agent.promptPlaceholder': 'Instructions for the agent…',
'flows.nodeConfig.agent.agentRefLabel': 'Agent',
'flows.nodeConfig.agent.agentRefHint':
'Run this node as a registered agent — its tools and guardrails apply.',
'flows.nodeConfig.agent.agentRefInherit': 'Default (workflow builder)',
'flows.nodeConfig.agent.modelLabel': 'Model',
'flows.nodeConfig.agent.modelHint': 'Pick a capability tier — the workspace resolves the model.',
'flows.nodeConfig.agent.modelInherit': 'Default (inherit)',
'flows.nodeConfig.agent.modelHints': 'Model hints',
'flows.nodeConfig.agent.modelManagedTiers': 'Managed tiers',
'flows.nodeConfig.agent.modelCustom': 'Custom model…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'e.g. gpt-4o-mini',
'flows.nodeConfig.tool.slugLabel': 'Action',
@@ -4645,6 +4650,9 @@ const en: TranslationMap = {
'Call an HTTP endpoint on demand and parse the response into a usable shape.',
'flows.templates.ask-agent.name': 'Ask the agent',
'flows.templates.ask-agent.description': 'A simple manual trigger that hands a task to an agent.',
'flows.templates.opus-sonnet-brief.name': 'Research brief (Opus plans, Sonnet drafts)',
'flows.templates.opus-sonnet-brief.description':
'A reasoning-tier agent plans the brief, a chat-tier agent drafts it, then the result is shaped for you.',
'oauth.button.connecting': 'Connecting...',
'oauth.button.loopbackTimeout':
+9
View File
@@ -3921,6 +3921,11 @@ const messages: TranslationMap = {
'flows.nodeConfig.agent.modelHint':
'Elige un nivel de capacidad; el espacio de trabajo resuelve el modelo.',
'flows.nodeConfig.agent.modelInherit': 'Predeterminado (heredar)',
'flows.nodeConfig.agent.agentRefLabel': 'Agente',
'flows.nodeConfig.agent.agentRefHint':
'Ejecuta este nodo como un agente registrado: se aplican sus herramientas y salvaguardas.',
'flows.nodeConfig.agent.agentRefInherit': 'Predeterminado (constructor de flujos)',
'flows.nodeConfig.agent.modelManagedTiers': 'Niveles gestionados',
'flows.nodeConfig.agent.modelHints': 'Sugerencias de modelo',
'flows.nodeConfig.agent.modelCustom': 'Modelo personalizado…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'p. ej., gpt-4o-mini',
@@ -4027,6 +4032,10 @@ const messages: TranslationMap = {
'flows.templates.ask-agent.name': 'Preguntar al agente',
'flows.templates.ask-agent.description':
'Un disparador manual sencillo que entrega una tarea a un agente.',
'flows.templates.opus-sonnet-brief.name':
'Informe de investigación (Opus planifica, Sonnet redacta)',
'flows.templates.opus-sonnet-brief.description':
'Un agente de nivel razonamiento planifica el informe, un agente de nivel chat lo redacta y luego se da forma al resultado para ti.',
'oauth.button.connecting': 'Conectando...',
'oauth.button.loopbackTimeout':
+8
View File
@@ -3937,6 +3937,11 @@ const messages: TranslationMap = {
'flows.nodeConfig.agent.modelHint':
'Choisissez un niveau de capacité — lespace de travail résout le modèle.',
'flows.nodeConfig.agent.modelInherit': 'Par défaut (hériter)',
'flows.nodeConfig.agent.agentRefLabel': 'Agent',
'flows.nodeConfig.agent.agentRefHint':
"Exécuter ce nœud comme un agent enregistré : ses outils et garde-fous s'appliquent.",
'flows.nodeConfig.agent.agentRefInherit': 'Par défaut (constructeur de workflow)',
'flows.nodeConfig.agent.modelManagedTiers': 'Niveaux gérés',
'flows.nodeConfig.agent.modelHints': 'Indications de modèle',
'flows.nodeConfig.agent.modelCustom': 'Modèle personnalisé…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'p. ex. gpt-4o-mini',
@@ -4045,6 +4050,9 @@ const messages: TranslationMap = {
'flows.templates.ask-agent.name': "Demander à l'agent",
'flows.templates.ask-agent.description':
'Un simple déclencheur manuel qui confie une tâche à un agent.',
'flows.templates.opus-sonnet-brief.name': 'Note de recherche (Opus planifie, Sonnet rédige)',
'flows.templates.opus-sonnet-brief.description':
'Un agent de niveau raisonnement planifie la note, un agent de niveau chat la rédige, puis le résultat est mis en forme pour vous.',
'oauth.button.connecting': 'Connexion en cours…',
'oauth.button.loopbackTimeout':
+9
View File
@@ -3856,6 +3856,11 @@ const messages: TranslationMap = {
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint': 'क्षमता स्तर चुनें — वर्कस्पेस मॉडल तय करेगा।',
'flows.nodeConfig.agent.modelInherit': 'डिफ़ॉल्ट (विरासत में)',
'flows.nodeConfig.agent.agentRefLabel': 'एजेंट',
'flows.nodeConfig.agent.agentRefHint':
'इस नोड को एक पंजीकृत एजेंट के रूप में चलाएँ — इसके उपकरण और सुरक्षा उपाय लागू होते हैं।',
'flows.nodeConfig.agent.agentRefInherit': 'डिफ़ॉल्ट (वर्कफ़्लो बिल्डर)',
'flows.nodeConfig.agent.modelManagedTiers': 'प्रबंधित स्तर',
'flows.nodeConfig.agent.modelHints': 'मॉडल संकेत',
'flows.nodeConfig.agent.modelCustom': 'कस्टम मॉडल…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'जैसे gpt-4o-mini',
@@ -3959,6 +3964,10 @@ const messages: TranslationMap = {
'flows.templates.ask-agent.name': 'एजेंट से पूछें',
'flows.templates.ask-agent.description':
'एक सरल मैन्युअल ट्रिगर जो किसी कार्य को एजेंट को सौंपता है।',
'flows.templates.opus-sonnet-brief.name':
'शोध सारांश (Opus योजना बनाता है, Sonnet मसौदा तैयार करता है)',
'flows.templates.opus-sonnet-brief.description':
'एक रीज़निंग-टियर एजेंट सारांश की योजना बनाता है, एक चैट-टियर एजेंट उसका मसौदा तैयार करता है, फिर परिणाम आपके लिए आकार दिया जाता है।',
'oauth.button.connecting': 'कनेक्ट हो रहा है...',
'oauth.button.loopbackTimeout':
+8
View File
@@ -3865,6 +3865,11 @@ const messages: TranslationMap = {
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint': 'Pilih tingkat kemampuan — workspace akan menentukan model.',
'flows.nodeConfig.agent.modelInherit': 'Default (warisi)',
'flows.nodeConfig.agent.agentRefLabel': 'Agen',
'flows.nodeConfig.agent.agentRefHint':
'Jalankan node ini sebagai agen terdaftar — alat dan pengamannya berlaku.',
'flows.nodeConfig.agent.agentRefInherit': 'Bawaan (pembuat alur kerja)',
'flows.nodeConfig.agent.modelManagedTiers': 'Tingkat terkelola',
'flows.nodeConfig.agent.modelHints': 'Petunjuk model',
'flows.nodeConfig.agent.modelCustom': 'Model kustom…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'mis. gpt-4o-mini',
@@ -3969,6 +3974,9 @@ const messages: TranslationMap = {
'flows.templates.ask-agent.name': 'Tanya agen',
'flows.templates.ask-agent.description':
'Pemicu manual sederhana yang menyerahkan tugas ke agen.',
'flows.templates.opus-sonnet-brief.name': 'Ringkasan riset (Opus merencanakan, Sonnet menyusun)',
'flows.templates.opus-sonnet-brief.description':
'Agen tingkat penalaran merencanakan ringkasan, agen tingkat obrolan menyusunnya, lalu hasilnya dibentuk untuk Anda.',
'oauth.button.connecting': 'Menghubungkan...',
'oauth.button.loopbackTimeout':
+8
View File
@@ -3915,6 +3915,11 @@ const messages: TranslationMap = {
'flows.nodeConfig.agent.modelHint':
'Scegli un livello di capacità — lo spazio di lavoro risolve il modello.',
'flows.nodeConfig.agent.modelInherit': 'Predefinito (eredita)',
'flows.nodeConfig.agent.agentRefLabel': 'Agente',
'flows.nodeConfig.agent.agentRefHint':
'Esegui questo nodo come agente registrato: si applicano i suoi strumenti e le sue protezioni.',
'flows.nodeConfig.agent.agentRefInherit': 'Predefinito (costruttore di flussi)',
'flows.nodeConfig.agent.modelManagedTiers': 'Livelli gestiti',
'flows.nodeConfig.agent.modelHints': 'Suggerimenti modello',
'flows.nodeConfig.agent.modelCustom': 'Modello personalizzato…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'es. gpt-4o-mini',
@@ -4023,6 +4028,9 @@ const messages: TranslationMap = {
'flows.templates.ask-agent.name': "Chiedi all'agente",
'flows.templates.ask-agent.description':
'Un semplice trigger manuale che affida un compito a un agente.',
'flows.templates.opus-sonnet-brief.name': 'Brief di ricerca (Opus pianifica, Sonnet redige)',
'flows.templates.opus-sonnet-brief.description':
'Un agente di livello ragionamento pianifica il brief, un agente di livello chat lo redige, poi il risultato viene formattato per te.',
'oauth.button.connecting': 'Connessione...',
'oauth.button.loopbackTimeout':
+8
View File
@@ -3816,6 +3816,11 @@ const messages: TranslationMap = {
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint': '기능 등급을 선택하세요. 작업 공간이 모델을 결정합니다.',
'flows.nodeConfig.agent.modelInherit': '기본값 (상속)',
'flows.nodeConfig.agent.agentRefLabel': '에이전트',
'flows.nodeConfig.agent.agentRefHint':
'이 노드를 등록된 에이전트로 실행합니다 — 해당 에이전트의 도구와 가드레일이 적용됩니다.',
'flows.nodeConfig.agent.agentRefInherit': '기본값 (워크플로 빌더)',
'flows.nodeConfig.agent.modelManagedTiers': '관리형 등급',
'flows.nodeConfig.agent.modelHints': '모델 힌트',
'flows.nodeConfig.agent.modelCustom': '사용자 지정 모델…',
'flows.nodeConfig.agent.modelCustomPlaceholder': '예: gpt-4o-mini',
@@ -3917,6 +3922,9 @@ const messages: TranslationMap = {
'요청 시 HTTP 엔드포인트를 호출하고 응답을 사용 가능한 형태로 파싱합니다.',
'flows.templates.ask-agent.name': '에이전트에게 묻기',
'flows.templates.ask-agent.description': '작업을 에이전트에 넘기는 간단한 수동 트리거입니다.',
'flows.templates.opus-sonnet-brief.name': '리서치 브리프 (Opus가 계획하고 Sonnet이 작성)',
'flows.templates.opus-sonnet-brief.description':
'추론 등급 에이전트가 브리프를 계획하고, 채팅 등급 에이전트가 초안을 작성한 다음, 결과가 사용자를 위해 정리됩니다.',
'oauth.button.connecting': '연결 중...',
'oauth.button.loopbackTimeout':
+8
View File
@@ -3903,6 +3903,11 @@ const messages: TranslationMap = {
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint': 'Wybierz poziom możliwości — obszar roboczy rozwiąże model.',
'flows.nodeConfig.agent.modelInherit': 'Domyślnie (dziedzicz)',
'flows.nodeConfig.agent.agentRefLabel': 'Agent',
'flows.nodeConfig.agent.agentRefHint':
'Uruchom ten węzeł jako zarejestrowanego agenta — obowiązują jego narzędzia i zabezpieczenia.',
'flows.nodeConfig.agent.agentRefInherit': 'Domyślny (kreator przepływów)',
'flows.nodeConfig.agent.modelManagedTiers': 'Zarządzane poziomy',
'flows.nodeConfig.agent.modelHints': 'Wskazówki modelu',
'flows.nodeConfig.agent.modelCustom': 'Model niestandardowy…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'np. gpt-4o-mini',
@@ -4010,6 +4015,9 @@ const messages: TranslationMap = {
'flows.templates.ask-agent.name': 'Zapytaj agenta',
'flows.templates.ask-agent.description':
'Prosty ręczny wyzwalacz, który przekazuje zadanie agentowi.',
'flows.templates.opus-sonnet-brief.name': 'Brief badawczy (Opus planuje, Sonnet redaguje)',
'flows.templates.opus-sonnet-brief.description':
'Agent klasy rozumowania planuje brief, agent klasy czatu go redaguje, a następnie wynik zostaje ukształtowany dla Ciebie.',
'oauth.button.connecting': 'Łączenie...',
'oauth.button.loopbackTimeout':
+8
View File
@@ -3916,6 +3916,11 @@ const messages: TranslationMap = {
'flows.nodeConfig.agent.modelHint':
'Escolha um nível de capacidade — o workspace resolve o modelo.',
'flows.nodeConfig.agent.modelInherit': 'Padrão (herdar)',
'flows.nodeConfig.agent.agentRefLabel': 'Agente',
'flows.nodeConfig.agent.agentRefHint':
'Executar este nó como um agente registrado — suas ferramentas e proteções se aplicam.',
'flows.nodeConfig.agent.agentRefInherit': 'Padrão (construtor de fluxos)',
'flows.nodeConfig.agent.modelManagedTiers': 'Níveis gerenciados',
'flows.nodeConfig.agent.modelHints': 'Dicas de modelo',
'flows.nodeConfig.agent.modelCustom': 'Modelo personalizado…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'ex.: gpt-4o-mini',
@@ -4021,6 +4026,9 @@ const messages: TranslationMap = {
'flows.templates.ask-agent.name': 'Perguntar ao agente',
'flows.templates.ask-agent.description':
'Um acionador manual simples que entrega uma tarefa a um agente.',
'flows.templates.opus-sonnet-brief.name': 'Resumo de pesquisa (Opus planeja, Sonnet redige)',
'flows.templates.opus-sonnet-brief.description':
'Um agente de nível raciocínio planeja o resumo, um agente de nível chat o redige e, em seguida, o resultado é formatado para você.',
'oauth.button.connecting': 'Conectando...',
'oauth.button.loopbackTimeout':
+9
View File
@@ -3894,6 +3894,11 @@ const messages: TranslationMap = {
'flows.nodeConfig.agent.modelHint':
'Выберите уровень возможностей — рабочая область определит модель.',
'flows.nodeConfig.agent.modelInherit': 'По умолчанию (наследовать)',
'flows.nodeConfig.agent.agentRefLabel': 'Агент',
'flows.nodeConfig.agent.agentRefHint':
'Запускать этот узел как зарегистрированного агента — применяются его инструменты и ограничения.',
'flows.nodeConfig.agent.agentRefInherit': 'По умолчанию (конструктор рабочих процессов)',
'flows.nodeConfig.agent.modelManagedTiers': 'Управляемые уровни',
'flows.nodeConfig.agent.modelHints': 'Подсказки модели',
'flows.nodeConfig.agent.modelCustom': 'Пользовательская модель…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'например, gpt-4o-mini',
@@ -3999,6 +4004,10 @@ const messages: TranslationMap = {
'Вызовите конечную точку HTTP по запросу и разберите ответ в удобную форму.',
'flows.templates.ask-agent.name': 'Спросить агента',
'flows.templates.ask-agent.description': 'Простой ручной триггер, передающий задачу агенту.',
'flows.templates.opus-sonnet-brief.name':
'Исследовательская сводка (Opus планирует, Sonnet пишет)',
'flows.templates.opus-sonnet-brief.description':
'Агент уровня рассуждений планирует сводку, агент уровня чата составляет черновик, затем результат оформляется для вас.',
'oauth.button.connecting': 'Подключение...',
'oauth.button.loopbackTimeout':
+7
View File
@@ -3656,6 +3656,10 @@ const messages: TranslationMap = {
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint': '选择能力层级,工作区会解析具体模型。',
'flows.nodeConfig.agent.modelInherit': '默认(继承)',
'flows.nodeConfig.agent.agentRefLabel': '智能体',
'flows.nodeConfig.agent.agentRefHint': '将此节点作为已注册的智能体运行——其工具和防护措施将生效。',
'flows.nodeConfig.agent.agentRefInherit': '默认(工作流构建器)',
'flows.nodeConfig.agent.modelManagedTiers': '托管层级',
'flows.nodeConfig.agent.modelHints': '模型提示',
'flows.nodeConfig.agent.modelCustom': '自定义模型…',
'flows.nodeConfig.agent.modelCustomPlaceholder': '例如 gpt-4o-mini',
@@ -3747,6 +3751,9 @@ const messages: TranslationMap = {
'flows.templates.http-fetch-parse.description': '按需调用 HTTP 端点,并将响应解析为可用的结构。',
'flows.templates.ask-agent.name': '询问智能体',
'flows.templates.ask-agent.description': '一个简单的手动触发器,将任务交给智能体。',
'flows.templates.opus-sonnet-brief.name': '研究简报(Opus 规划,Sonnet 起草)',
'flows.templates.opus-sonnet-brief.description':
'推理层级的智能体规划简报,聊天层级的智能体起草,然后结果会为你整理成形。',
'oauth.button.connecting': '连接中...',
'oauth.button.loopbackTimeout': '登录超时 — 浏览器未完成 OAuth 跳转。请重试。',
+35 -3
View File
@@ -23,7 +23,9 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom';
import FlowCanvas from '../components/flows/canvas/FlowCanvas';
import FlowRunsSidebar from '../components/flows/FlowRunsSidebar';
import WorkflowCopilotPanel from '../components/flows/WorkflowCopilotPanel';
import WorkflowCopilotPanel, {
type RepairPromptContext,
} from '../components/flows/WorkflowCopilotPanel';
import {
getCopilotThreadId,
setCopilotThreadId as setCopilotThreadIdCache,
@@ -37,7 +39,6 @@ import { asFlowCanvasDraftState } from '../lib/flows/canvasDraft';
import { workflowGraphToXyflow } from '../lib/flows/graphAdapter';
import { buildPreviewGraph, diffGraphs } from '../lib/flows/graphDiff';
import type { WorkflowGraph } from '../lib/flows/types';
import { type RepairPromptContext } from '../lib/flows/workflowBuilderPrompt';
import { useT } from '../lib/i18n/I18nContext';
import { createFlow, type Flow, getFlow, runFlow, updateFlow } from '../services/api/flowsApi';
import type { WorkflowProposal } from '../store/chatRuntimeSlice';
@@ -54,6 +55,27 @@ export interface CopilotRepairSeed {
failingNodeIds?: string[];
}
/**
* Seed for opening the canvas copilot preloaded from the Flows prompt bar
* (instant-create): the flow was just created blank, and the copilot should
* open already building the described workflow. Rides in `location.state`
* (ephemeral — lost on hard reload, which just leaves a blank flow to edit).
*/
export interface CopilotBuildSeed {
/** The user's free-text workflow description from the prompt bar. */
description: string;
}
/** Narrow an opaque `location.state` to a {@link CopilotBuildSeed}. */
export function asCopilotBuildSeed(state: unknown): CopilotBuildSeed | null {
if (!state || typeof state !== 'object') return null;
const seed = (state as Record<string, unknown>).copilotBuild;
if (!seed || typeof seed !== 'object') return null;
const description = (seed as Record<string, unknown>).description;
if (typeof description !== 'string' || description.trim().length === 0) return null;
return { description };
}
/** Narrow an opaque `location.state` to a {@link CopilotRepairSeed}. */
export function asCopilotRepairSeed(state: unknown): CopilotRepairSeed | null {
if (!state || typeof state !== 'object') return null;
@@ -114,9 +136,12 @@ interface EditorFlow {
function FlowEditor({
editorFlow,
initialCopilotSeed = null,
initialBuildSeed = null,
}: {
editorFlow: EditorFlow;
initialCopilotSeed?: CopilotRepairSeed | null;
/** Prompt-bar instant-create seed: open the copilot already building this. */
initialBuildSeed?: CopilotBuildSeed | null;
}) {
const { t } = useT();
const navigate = useNavigate();
@@ -175,7 +200,9 @@ function FlowEditor({
// the proposed graph plus ghosted removed nodes, painted diff-style. Accept
// commits the proposed graph into `draftGraph`; Reject reverts to the frozen
// base. NOTHING here persists — the canvas's own Save is the only gate.
const [copilotOpen, setCopilotOpen] = useState(initialCopilotSeed !== null);
const [copilotOpen, setCopilotOpen] = useState(
initialCopilotSeed !== null || initialBuildSeed !== null
);
// Per-workflow copilot thread: seeded from the session cache so opening/closing
// the panel (or switching flows and back) resumes the same conversation
// instead of starting a fresh `workflow_builder` thread each time.
@@ -511,6 +538,7 @@ function FlowEditor({
onReject={handleRejectProposal}
onClose={() => setCopilotOpen(false)}
repairSeed={copilotRepairSeed}
buildSeed={initialBuildSeed}
seedThreadId={copilotThreadId}
onThreadIdChange={handleCopilotThreadId}
/>
@@ -529,6 +557,9 @@ export default function FlowCanvasPage() {
// "Fix with agent" (Phase 5c) navigates here with a repair seed in
// `location.state` so the copilot opens preloaded with the failed run.
const copilotSeed = useMemo(() => asCopilotRepairSeed(location.state), [location.state]);
// The Flows prompt bar's instant-create path navigates here with a build
// seed so the copilot opens already building the described workflow.
const buildSeed = useMemo(() => asCopilotBuildSeed(location.state), [location.state]);
useEffect(() => {
// Guards a stale response from clobbering newer state: this effect
@@ -588,6 +619,7 @@ export default function FlowCanvasPage() {
requireApproval: flow.require_approval,
}}
initialCopilotSeed={copilotSeed}
initialBuildSeed={buildSeed}
/>
);
}
@@ -10,7 +10,7 @@ import { createMemoryRouter, MemoryRouter, Route, RouterProvider, Routes } from
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Flow } from '../../services/api/flowsApi';
import FlowCanvasPage, { FlowCanvasDraftPage } from '../FlowCanvasPage';
import FlowCanvasPage, { asCopilotBuildSeed, FlowCanvasDraftPage } from '../FlowCanvasPage';
const getFlow = vi.hoisted(() => vi.fn());
const updateFlow = vi.hoisted(() => vi.fn());
@@ -25,6 +25,17 @@ vi.mock('../../services/api/flowsApi', () => ({
listFlowConnections,
}));
// Stub the copilot panel: it drives the real chat runtime (redux + socket),
// which is out of scope here — we only assert the host opens it and hands the
// right seed through.
const copilotPanelProps = vi.hoisted(() => ({ current: null as Record<string, unknown> | null }));
vi.mock('../../components/flows/WorkflowCopilotPanel', () => ({
default: (props: Record<string, unknown>) => {
copilotPanelProps.current = props;
return <div data-testid="stub-copilot-panel" />;
},
}));
function makeFlow(overrides: Partial<Flow> = {}): Flow {
return {
id: 'test-id',
@@ -276,3 +287,57 @@ describe('FlowCanvasPage', () => {
expect(screen.queryByTestId('flow-canvas')).not.toBeInTheDocument();
});
});
describe('asCopilotBuildSeed', () => {
it('accepts a copilotBuild state with a non-empty description', () => {
expect(asCopilotBuildSeed({ copilotBuild: { description: 'digest my Slack' } })).toEqual({
description: 'digest my Slack',
});
});
it('rejects missing, malformed, or blank seeds', () => {
expect(asCopilotBuildSeed(null)).toBeNull();
expect(asCopilotBuildSeed({})).toBeNull();
expect(asCopilotBuildSeed({ copilotBuild: 'digest' })).toBeNull();
expect(asCopilotBuildSeed({ copilotBuild: { description: 42 } })).toBeNull();
expect(asCopilotBuildSeed({ copilotBuild: { description: ' ' } })).toBeNull();
});
});
describe('FlowCanvasPage copilot build seed (prompt-bar instant create)', () => {
beforeEach(() => {
copilotPanelProps.current = null;
getFlow.mockReset();
getFlow.mockResolvedValue(makeFlow());
});
it('opens the copilot preloaded with the build seed from location.state', async () => {
render(
<MemoryRouter
initialEntries={[
{ pathname: '/flows/test-id', state: { copilotBuild: { description: 'digest it' } } },
]}>
<Routes>
<Route path="/flows/:id" element={<FlowCanvasPage />} />
</Routes>
</MemoryRouter>
);
await waitFor(() => expect(screen.getByTestId('stub-copilot-panel')).toBeInTheDocument());
expect(copilotPanelProps.current?.buildSeed).toEqual({ description: 'digest it' });
expect(copilotPanelProps.current?.flowId).toBe('test-id');
});
it('keeps the copilot closed without a seed', async () => {
render(
<MemoryRouter initialEntries={['/flows/test-id']}>
<Routes>
<Route path="/flows/:id" element={<FlowCanvasPage />} />
</Routes>
</MemoryRouter>
);
await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
expect(screen.queryByTestId('stub-copilot-panel')).not.toBeInTheDocument();
});
});
+71
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
buildWorkflow,
discoverWorkflows,
dismissSuggestion,
type FlowSuggestion,
@@ -299,6 +300,18 @@ describe('flowsApi', () => {
expect(result).toEqual([suggestion]);
});
it('discoverWorkflows passes thread_id when a chat thread is given', async () => {
mockCallCoreRpc.mockResolvedValue(cliEnvelope([suggestion]));
await discoverWorkflows('scout-thread-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.flows_discover',
params: { thread_id: 'scout-thread-1' },
timeoutMs: 310_000,
});
});
it('listSuggestions omits status when not provided', async () => {
mockCallCoreRpc.mockResolvedValue(cliEnvelope([]));
@@ -352,4 +365,62 @@ describe('flowsApi', () => {
await expect(discoverWorkflows()).rejects.toThrow('boom');
});
});
describe('buildWorkflow', () => {
const proposalPayload = {
type: 'workflow_proposal',
name: 'Digest',
graph: { schema_version: 1, name: 'g', nodes: [], edges: [] },
require_approval: true,
summary: { trigger: 'manual', steps: [] },
};
it('calls flows_build with the structured request and no thread_id when omitted', async () => {
mockCallCoreRpc.mockResolvedValue(
cliEnvelope({ proposal: proposalPayload, assistant_text: 'here you go', error: null })
);
const result = await buildWorkflow({ mode: 'create', instruction: 'email me a digest' });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.flows_build',
params: {
mode: 'create',
instruction: 'email me a digest',
graph: null,
flow_id: null,
run_id: null,
error: null,
failing_node_ids: [],
},
timeoutMs: 310_000,
});
expect(result.assistantText).toBe('here you go');
expect(result.proposal?.name).toBe('Digest');
expect(result.error).toBeNull();
});
it('threads the chat thread_id into flows_build params when provided', async () => {
mockCallCoreRpc.mockResolvedValue(
cliEnvelope({ proposal: null, assistant_text: '', error: null })
);
await buildWorkflow({ mode: 'revise', instruction: 'add a Slack step' }, 'builder-thread-9');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.flows_build',
params: {
mode: 'revise',
instruction: 'add a Slack step',
graph: null,
flow_id: null,
run_id: null,
error: null,
failing_node_ids: [],
thread_id: 'builder-thread-9',
},
timeoutMs: 310_000,
});
});
});
});
+141 -3
View File
@@ -28,6 +28,8 @@
*/
import debug from 'debug';
import type { WorkflowGraph } from '../../lib/flows/types';
import type { WorkflowProposal } from '../../store/chatRuntimeSlice';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('flowsApi');
@@ -519,11 +521,18 @@ const FLOW_DISCOVER_TIMEOUT_MS = 310_000;
* creates, enables, or runs a flow. Returns the `FlowSuggestion[]` directly
* (same no-wrapper shape as `flows_list`).
*/
export async function discoverWorkflows(): Promise<FlowSuggestion[]> {
log('discoverWorkflows: request');
export async function discoverWorkflows(threadId?: string | null): Promise<FlowSuggestion[]> {
log('discoverWorkflows: request thread=%s', threadId ?? '<none>');
// When a caller passes a chat thread id, the server streams the Flow Scout
// turn's text/tool events onto that thread (Phase B) so a shared chat pane can
// render them live. The param name matches the `thread_id` convention in
// `src/openhuman/flows/schemas.rs` (a per-turn `request_id` is minted
// server-side when omitted). Omitting it keeps the headless behaviour.
const params: Record<string, unknown> = {};
if (threadId) params.thread_id = threadId;
const response = await callCoreRpc<unknown>({
method: 'openhuman.flows_discover',
params: {},
params,
timeoutMs: FLOW_DISCOVER_TIMEOUT_MS,
});
const suggestions = unwrapCliEnvelope<FlowSuggestion[]>(response);
@@ -547,6 +556,135 @@ export async function listSuggestions(status?: SuggestionStatus): Promise<FlowSu
return suggestions;
}
// ─────────────────────────────────────────────────────────────────────────────
// flows_build — run the workflow_builder agent for one authoring turn.
// ─────────────────────────────────────────────────────────────────────────────
/**
* Which authoring turn to run (mirrors the Rust `BuildMode`). The server renders
* the agent's natural-language brief from this — the frontend no longer crafts
* delegate prompts.
*/
export type BuilderTurnMode = 'create' | 'revise' | 'repair' | 'build';
/** A structured workflow-builder turn request. */
export interface BuilderTurnRequest {
/** Which kind of turn to run. */
mode: BuilderTurnMode;
/** The user's ask: description (create/build) or change instruction (revise). */
instruction: string;
/** The current draft graph, injected as context for revise/repair/build. */
graph?: WorkflowGraph | null;
/** Saved flow id (required for `build`; optional elsewhere for run-to-test). */
flowId?: string | null;
/** Failed run id (== thread id) for `repair`. */
runId?: string | null;
/** Run-level error message for `repair`, if known. */
error?: string | null;
/** Node ids implicated in the failure, for `repair`. */
failingNodeIds?: string[];
}
/** The result of one builder turn. */
export interface BuilderTurnResult {
/** The proposal the agent produced (mapped to the store shape), or null. */
proposal: WorkflowProposal | null;
/** The agent's final assistant text (rendered as its chat turn). */
assistantText: string;
/** A run error, if the turn failed but a prior proposal was still captured. */
error: string | null;
}
/**
* The `workflow_builder` agent can take up to ~300s server-side
* (`FLOW_BUILD_TIMEOUT_SECS` in `src/openhuman/flows/ops.rs`); match it so a slow
* authoring turn doesn't time out client-side while the agent is still working.
*/
const FLOW_BUILD_TIMEOUT_MS = 310_000;
/**
* Map a raw `{ type: 'workflow_proposal', … }` payload (from the agent's
* propose/revise/save tool) to the store {@link WorkflowProposal} shape. Kept in
* lockstep with `parseWorkflowProposal` in `ChatRuntimeProvider` (the streamed
* path); returns null if the payload isn't a valid proposal.
*/
export function mapWorkflowProposal(payload: unknown): WorkflowProposal | null {
if (!payload || typeof payload !== 'object') return null;
const obj = payload 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 omitted, so treat
// anything other than an explicit false as true — in lockstep with the server.
requireApproval: obj.require_approval !== false,
summary: { trigger: typeof summary.trigger === 'string' ? summary.trigger : '', steps },
};
}
/**
* Run one `workflow_builder` authoring turn via `openhuman.flows_build`. The
* server renders the agent's brief from `request`, runs the agent to completion,
* and returns its proposal + final assistant text. This is the backend-agent
* path that replaces the frontend's old "craft a delegate prompt and route it
* through the chat orchestrator" approach.
*/
export async function buildWorkflow(
request: BuilderTurnRequest,
threadId?: string | null
): Promise<BuilderTurnResult> {
log(
'buildWorkflow: request mode=%s flowId=%s thread=%s',
request.mode,
request.flowId ?? '<none>',
threadId ?? '<none>'
);
const params: Record<string, unknown> = {
mode: request.mode,
instruction: request.instruction,
graph: request.graph ?? null,
flow_id: request.flowId ?? null,
run_id: request.runId ?? null,
error: request.error ?? null,
failing_node_ids: request.failingNodeIds ?? [],
};
// When the copilot passes its dedicated chat thread id, the server streams the
// builder turn's text/thinking/tool events onto that thread (Phase B) so the
// shared chat pane renders them live and `ChatRuntimeProvider` appends the
// final assistant message on `chat_done`. Param name matches the `thread_id`
// convention in `src/openhuman/flows/schemas.rs`; a per-turn `request_id` is
// minted server-side when omitted. Omitting it keeps the headless behaviour.
if (threadId) params.thread_id = threadId;
const response = await callCoreRpc<unknown>({
method: 'openhuman.flows_build',
params,
timeoutMs: FLOW_BUILD_TIMEOUT_MS,
});
const result = unwrapCliEnvelope<{
proposal: unknown;
assistant_text: string;
error: string | null;
}>(response);
log('buildWorkflow: response hasProposal=%s', result.proposal != null);
return {
proposal: mapWorkflowProposal(result.proposal),
assistantText: result.assistant_text ?? '',
error: result.error ?? null,
};
}
/**
* Dismiss a suggestion via `openhuman.flows_dismiss_suggestion` (the user
* rejected the card). The row is kept server-side so a later discovery run
+1
View File
@@ -87,6 +87,7 @@
* [Agent Observability](developing/agent-observability.md)
* [Architecture](developing/architecture/README.md)
* [Agent Harness](developing/architecture/agent-harness.md)
* [Flows on TinyAgents (`src/openhuman/flows/`)](developing/architecture/flows-on-tinyagents.md)
* [Memory Tree (`src/openhuman/memory_tree/`)](developing/architecture/memory-tree.md)
* [MCP Registry (`src/openhuman/mcp_registry/`)](developing/architecture/mcp-registry.md)
* [Security (`src/openhuman/security/`)](developing/architecture/security.md)
@@ -0,0 +1,347 @@
---
description: >-
How a saved flow runs - lowering a WorkflowGraph onto tinyagents, the
{run,nodes} run-state, the capability seam, agent-nodes-as-nested-graphs, the
two-layer security model, and the one-trigger model.
icon: diagram-project
---
# Flows on TinyAgents
The **flows** domain ([`src/openhuman/flows/`](../../../src/openhuman/flows/)) drives
saved automations - the workflows a user builds in the canvas or the copilot
builds for them. It does **not** contain a workflow engine. Every flow is run by
the vendored, host-agnostic [`tinyflows`](../../../vendor/tinyflows/) crate, which
**lowers each workflow onto the same [`tinyagents`](https://crates.io/crates/tinyagents)
state-graph engine that the [agent harness](agent-harness.md) runs on.** So a
saved flow is a tinyagents graph, and (after harness unification) each of its
`agent` nodes is *itself* a tinyagents graph - a graph within a graph.
This page is the flows counterpart to [Agent Harness](agent-harness.md): where
that page explains how one agent *turn* runs, this one explains how one *flow*
runs, and how the two runtimes compose.
## Two crates, one engine
| Crate | Role | Where |
| --- | --- | --- |
| `tinyflows` | Host-agnostic workflow model + validate + compile + run. Never hard-codes a vendor; every outside-world effect goes through a capability trait. | [`vendor/tinyflows/`](../../../vendor/tinyflows/) |
| `tinyagents` | The published state-graph + agent-loop harness both runtimes lower onto. | crate; OpenHuman seam in [`src/openhuman/tinyagents/`](../../../src/openhuman/tinyagents/) |
| `openhuman::flows` | The host: CRUD/run/resume RPCs, SQLite store, triggers, the builder/scout agents. | [`src/openhuman/flows/`](../../../src/openhuman/flows/) |
| `openhuman::tinyflows` | The **capability seam** - adapters implementing the `tinyflows` traits over real OpenHuman services. | [`src/openhuman/tinyflows/`](../../../src/openhuman/tinyflows/) |
`tinyflows` is published to crates.io and is deliberately persistence-free and
vendor-free (see its [`CLAUDE.md`](../../../vendor/tinyflows/CLAUDE.md)); OpenHuman
is the first downstream host and injects everything real through the seam.
## The run pipeline
A flow is a [`WorkflowGraph`](../../../vendor/tinyflows/src/model/) - a directed
graph of typed [`Node`]s joined by [`Edge`]s, JSON on the wire. Running it is a
fixed four-stage pipeline
([`vendor/tinyflows/src/lib.rs`](../../../vendor/tinyflows/src/lib.rs),
[`compiler.rs`](../../../vendor/tinyflows/src/compiler.rs),
[`engine.rs`](../../../vendor/tinyflows/src/engine.rs)):
```mermaid
flowchart LR
subgraph host["openhuman::flows (host)"]
store[(SQLite<br/>flow store)] --> graph[WorkflowGraph<br/>JSON]
end
graph --> validate["validate<br/>(structural)"]
validate --> compile["compiler::compile<br/>(lower onto tinyagents)"]
compile --> gb["tinyagents<br/>GraphBuilder"]
gb --> run["engine::run_with_checkpointer<br/>(drive to completion)"]
run --> outcome["RunOutcome<br/>{ output, pending_approvals, cancelled }"]
seam["openhuman::tinyflows<br/>capability seam"] -. host-injected .-> run
```
1. **validate** ([`validate.rs`](../../../vendor/tinyflows/src/validate.rs)) -
structural checks over the raw graph: unique node ids, that every referenced
node exists, and **exactly one trigger node** (0 → `MissingTrigger`, >1 →
`MultipleTriggers`). This is the single-trigger invariant the whole model
rests on (see [Trigger model](#the-trigger-model)).
2. **compile** ([`compiler.rs`](../../../vendor/tinyflows/src/compiler.rs)) - runs
validation, then lowers the validated graph onto a fresh `tinyagents` state
graph. **Every tinyflows node becomes a tinyagents graph node**; every
tinyflows edge becomes a graph edge, with conditional/parallel routing and a
merge barrier expressed on the tinyagents graph layer. The graph is rebuilt
per run so compilation stays independent of any host state.
3. **run** ([`engine.rs`](../../../vendor/tinyflows/src/engine.rs)) - the host
calls `engine::run_with_checkpointer_journaled_observed`
([`flows/ops.rs`](../../../src/openhuman/flows/ops.rs), `flows_run`), which
drives the compiled graph to completion, folding each node's output into the
run state and pausing at approval gates.
4. **outcome** - a `RunOutcome { output, pending_approvals, cancelled }`; the host
persists live steps through the `FlowRunObserver` and exports the durable
graph observations to Langfuse
([`tinyflows/langfuse_export.rs`](../../../src/openhuman/tinyflows/langfuse_export.rs)).
Because the engine keys persisted state by a caller-supplied `thread_id`,
durable **HITL resume** is `engine::resume_with_checkpointer` over the same
`tinyagents::graph::SqliteCheckpointer` the agent harness uses - opened once per
host at `<workspace_dir>/flows/checkpoints.db`
([`caps.rs`](../../../src/openhuman/tinyflows/caps.rs), `open_flow_checkpointer`).
## Run state: one JSON map, a merge reducer, and the `{json,text,raw}` envelope
The entire run's working memory is a single `serde_json::Value` laid out as
([`vendor/tinyflows/src/engine.rs`](../../../vendor/tinyflows/src/engine.rs)):
```json
{
"run": { "trigger": { /* the trigger payload seeded at start */ } },
"nodes": {
"planner": { "items": [ /* */ ] },
"drafter": { "items": [ /* */ ] }
}
}
```
As the graph executes, a **merge reducer** folds each node's item output into
`nodes.<id>`. Because merging is additive rather than last-writer-wins, a
**fan-in** node can see every predecessor's output at once, and a **fan-out**
(`split_out`) node's parallel branches merge back at the barrier without
clobbering one another - the same reducer pattern the tinyagents graph layer uses
for its own `map_reduce` fan-out (see [Agent Harness](agent-harness.md)).
Every **capability node** (agent / tool_call / http_request / code) emits its
output wrapped in a stable **`{ json, text, raw }` envelope**. Downstream nodes
never see the raw completion shape - they bind against the envelope:
- `=item.json.<field>` - the structured object (when the producer emitted JSON);
- `=item.text` - the prose form;
- `=item.raw` - the untouched producer output.
### The `=`-expression scope
Node config is resolved through jq/jaq `=`-expressions
([`vendor/tinyflows/src/expr.rs`](../../../vendor/tinyflows/src/expr.rs)) against a
per-node **scope** with four bindings:
| Binding | What it holds |
| --- | --- |
| `item` | the first input item's `json` (the direct predecessor's output) |
| `items` | every input item's `json`, in edge order |
| `run` | run metadata and the **trigger payload** (`run.trigger`) |
| `nodes` | **every completed node's output, keyed by id**: `{ "<id>": { "item": …, "items": [ … ] } }` |
So a node three hops downstream can reach back to a specific ancestor by id -
`"=nodes.planner.item.json.plan"` - which is exactly how the demo workflow passes
the planner's structured plan into the drafter's prompt. A `"=item.text"` form is
the short-hand for the immediate predecessor. Missing segments resolve to `null`
rather than erroring, and a malformed jq program yields `null` rather than
panicking - node wiring never crashes the run.
## The capability seam
`tinyflows` touches nothing real on its own. Everything - LLM calls, tools, HTTP,
code, persistence, sub-workflow lookup - is a **capability trait** the host
implements ([`vendor/tinyflows/src/caps/mod.rs`](../../../vendor/tinyflows/src/caps/mod.rs)).
`openhuman::tinyflows::caps` supplies one adapter per trait, assembled into a
`Capabilities` bundle per run by `build_capabilities`
([`caps.rs`](../../../src/openhuman/tinyflows/caps.rs)):
| tinyflows trait | Node(s) it backs | OpenHuman adapter | Wraps |
| --- | --- | --- | --- |
| `LlmProvider` | `agent` (bare), `output_parser` | `OpenHumanLlm` | `inference/provider` (`create_chat_provider`) |
| `AgentRunner` | `agent` (with `agent_ref`) | `OpenHumanAgentRunner` | the agent registry + harness `Agent` |
| `ToolInvoker` | `tool_call` | `OpenHumanTools` | Composio + native `oh:` tools |
| `HttpClient` | `http_request` | `OpenHumanHttp` | `HttpRequestTool` (allowlist + DNS-rebind guard) |
| `CodeRunner` | `code` | `OpenHumanCode` | the sandbox (`execute_in_sandbox`) |
| `StateStore` | resumable/stateful runs | `FlowStateStore` | the `flow_state` KV table |
| `WorkflowResolver` | `sub_workflow` by id | `OpenHumanWorkflowResolver` | the saved-flow store (`load_flow_graph`) |
`AgentRunner` is **optional** in the crate (`Capabilities::agent` is
`Option`): a host without an agent registry leaves it `None` and `agent` nodes
fall back to a bare `LlmProvider` completion. OpenHuman always wires it, so
`agent` nodes get the real agent runtime described next.
## Agent nodes: a graph within the graph
A flow `agent` node names a **registered agent kind** through a trusted
`agent_ref` in its config (researcher, code_executor, a custom specialist, …).
`OpenHumanAgentRunner::run_agent` resolves that ref and routes on what it finds:
- **A harness `AgentDefinition` exists** → build a full harness `Agent`
(`Agent::from_config_for_agent`) and run the node's request through
`run_single`. That is the *same* entry the builder/scout and cron/subconscious
jobs use, and internally it drives `run_turn_via_tinyagents_shared`
([`src/openhuman/tinyagents/mod.rs`](../../../src/openhuman/tinyagents/mod.rs)) -
the tinyagents `AgentHarness` tool-call loop. The definition's ToolScope,
`sandbox_mode`, and `max_iterations` govern the inner turn.
- **Only a custom `AgentRegistryEntry` exists** (no full definition) → the
persona-shaping fallback: the entry's `system_prompt` (and model, when the node
didn't pin one) is prepended and the request runs through
`OpenHumanLlm::complete` - a single completion, no private tool loop. This
keeps custom registry agents working without a regression.
The consequence is the nesting the plan set out to achieve: **the flow is a
tinyagents graph whose `agent` nodes each run a nested tinyagents graph (one
agent turn).**
```mermaid
flowchart TB
subgraph flow["Flow = tinyagents graph (compiled WorkflowGraph)"]
t[trigger] --> a["agent node<br/>agent_ref = planner"]
a --> tc[tool_call node]
tc --> tr[transform node]
end
a -.->|"run_agent → Agent::from_config_for_agent → run_single"| inner
subgraph inner["Agent turn = nested tinyagents graph (AgentHarness)"]
p[provider call] --> parse[parse tool calls]
parse --> tools[dispatch tools]
tools --> compact[context guard]
compact --> p
parse --> done[final text]
end
done -.->|"{ json, text, raw } envelope"| tc
```
Either path returns a JSON value; the `agent` node folds it into the
`{ json, text, raw }` envelope (with an `output_parser` sub-port applying the
node's declared schema), so a downstream node's binding is identical whether the
node ran a full agent turn or a bare completion.
`agent_ref` is resolved **from trusted node config only, never from model
output**, so a prompt-injected upstream completion cannot pick an arbitrary agent
kind. The **builder's dry-run** exercises this path too:
`dry_run_workflow` compiles a draft and runs it against `tinyflows`' *mock*
capabilities wired with a `MockAgentRunner`
([`vendor/tinyflows/src/caps/mock.rs`](../../../vendor/tinyflows/src/caps/mock.rs)),
so a draft whose `agent` nodes carry an `agent_ref` is self-tested end-to-end
before it is ever saved.
## The security model: two gates
A flow run is guarded on **two independent layers**, an outer one owned by the
flows runtime and an inner one owned by the agent harness.
**Outer gate - the flow's origin + autonomy tier.** `flows_run`/`flows_resume`
scope a `TrustedAutomation { source: Workflow { require_approval } }` origin
around the whole engine future
([`flows/ops.rs`](../../../src/openhuman/flows/ops.rs), via
`with_origin`). Before an *acting* node dispatches, the seam consults the user's
`[autonomy]` tier through `SecurityPolicy::gate_decision` for that node's
`CommandClass` (`http_request` → Network, `code` → Write, native `oh:` tools →
their classified class) in `enforce_node_tier_gate`
([`caps.rs`](../../../src/openhuman/tinyflows/caps.rs)):
- a `readonly` run **`Block`s** at the network/code boundary and never dispatches;
- a `supervised` run's `Prompt` decision is escalated by `gate_call_for_tier`
into a **forced `ApprovalGate` round-trip** - even when the flow's own
`require_approval` is `false`, so the tier's "ask me" can't be silently
defeated by a saved flow's default trust;
- a `full` run passes through.
Composio `tool_call` nodes get an extra deny-by-default **curation gate**
(`is_curated_flow_tool`): a slug is allowed only if it resolves to a known,
curated, in-scope, connected toolkit action - stricter than the general agent
tool-call path, because a flow's slug is a free-form string the author typed
rather than one the backend returned from live discovery.
**Inner gate - the agent definition's ToolScope + sandbox.** When an `agent`
node runs a full harness `Agent`, that turn runs under the *same* `Workflow`
origin (the engine future is already inside `with_origin`), so the autonomy tier
and approval gate apply to the inner turn automatically - **no new origin
wrapper**. On top of that, the agent definition's own `ToolScope`,
`sandbox_mode`, and iteration cap bound what the turn can do, exactly as they do
for a chat sub-agent. So an `agent` node is gated twice: the flow's outer
autonomy/origin gate and the agent's inner tool/sandbox gate.
`agent_ref` being trusted-config-only is the third leg: untrusted trigger or
upstream data can influence *arguments* (bounded by the curation + scope +
approval checks) but never *which agent kind or tool identity* runs.
## The trigger model
`tinyflows` enforces **exactly one trigger node per graph** at validate time
([`validate.rs`](../../../vendor/tinyflows/src/validate.rs)). A workflow therefore
has a single entry point, and the trigger's payload is what seeds `run.trigger`
in the run state.
That is a *model-level* constraint, not a product limitation. **Multi-trigger is
a host-side concern today**, handled by the flows domain rather than the engine.
`FlowTriggerSubscriber` ([`flows/bus.rs`](../../../src/openhuman/flows/bus.rs)) is
the trigger → run bridge: it subscribes to the normalized domain events a saved
flow's single trigger node can bind to and calls `flows::ops::flows_run` for each
match:
- **`DomainEvent::FlowScheduleTick`** - a `flow`-type cron job fired; the one
named flow is loaded, re-checked for a still-live `schedule` trigger, and
dispatched with an empty trigger payload.
- **`DomainEvent::ComposioTriggerReceived`** - every enabled flow with an
`app_event` trigger bound to that `toolkit`/`trigger_slug` is dispatched with
the event payload seeded into `run.trigger`.
Dispatch is deduped per `flow_id` (a trigger burst can't spawn overlapping runs
for the same flow), while the interactive `flows_run` RPC is deliberately *not*
deduped - a user asking to run a flow again is always honored.
So "run this flow on a schedule **and** when a Gmail message arrives" is
expressed as two host subscriptions fanning into the same one-trigger graph.
**Model-level multiple triggers on one graph are future work**, tracked as such;
nothing in the seam or the store depends on it today.
## Builder, scout, and executor: one harness
The three agents that touch flows all run on the shared agent harness -
`Agent::from_config_for_agent``run_single` under a scoped origin - the same
pattern the flow's own `agent` nodes use:
| Agent | Registry id | Entry point | Tool belt |
| --- | --- | --- | --- |
| **Builder** (copilot) | `workflow_builder` | `flows_build` ([`ops.rs`](../../../src/openhuman/flows/ops.rs)) | `propose_workflow` / `revise_workflow` (validate-only), `dry_run_workflow` (compile + run vs. mocks), `save_workflow`, `run_workflow`, catalog/connection reads ([`builder_tools.rs`](../../../src/openhuman/flows/builder_tools.rs)) |
| **Scout** (discovery) | `flow_discovery` | `flows_discover` ([`ops.rs`](../../../src/openhuman/flows/ops.rs)) | `suggest_workflows` ([`discovery_tools.rs`](../../../src/openhuman/flows/discovery_tools.rs)) |
| **Executor** | *(n/a - the engine)* | `flows_run` / `flows_resume` | the capability seam above |
Both agents live under
[`src/openhuman/flows/agents/`](../../../src/openhuman/flows/agents/) as
first-class registry agents (`agent.toml` + `prompt.md`), on the reasoning tier
with narrow, safety-reviewed tool belts. The builder is tool-assisted
end-to-end: it *proposes* graphs (validate-only), *dry-runs* them against mock
capabilities as a self-test loop, and only then *saves* and confirm-first
*test-runs* a real flow. The full discover → build → save → run lifecycle:
```mermaid
sequenceDiagram
participant U as User
participant S as flow_discovery<br/>(scout)
participant B as workflow_builder<br/>(builder)
participant St as flow store<br/>(SQLite)
participant E as tinyflows engine
U->>S: flows_discover
S->>S: suggest_workflows (validate ideas)
S-->>U: FlowSuggestion[]
U->>B: flows_build (pick a suggestion / prompt)
B->>B: propose_workflow (validate draft)
B->>E: dry_run_workflow (compile + run vs. mocks)
E-->>B: mock RunOutcome (self-test)
B->>St: save_workflow (existing flow)
B-->>U: WorkflowProposal
U->>E: flows_run (confirm-first)
E->>E: validate → compile → run (real caps)
E-->>U: RunOutcome (live steps + Langfuse trace)
```
## Where to look in the code
| Path | What lives there |
| --- | --- |
| [`vendor/tinyflows/src/`](../../../vendor/tinyflows/src/) | The engine: `model/`, `validate.rs`, `compiler.rs`, `engine.rs`, `expr.rs`, `caps/`, `nodes/`. |
| [`vendor/tinyflows/src/caps/mod.rs`](../../../vendor/tinyflows/src/caps/mod.rs) | The seven capability traits + `Capabilities` bundle. |
| [`src/openhuman/tinyflows/caps.rs`](../../../src/openhuman/tinyflows/caps.rs) | The host adapters, `build_capabilities`, `open_flow_checkpointer`, the two-layer gate helpers. |
| [`src/openhuman/flows/ops.rs`](../../../src/openhuman/flows/ops.rs) | `flows_run` / `flows_resume` / `flows_build` / `flows_discover` and CRUD. |
| [`src/openhuman/flows/bus.rs`](../../../src/openhuman/flows/bus.rs) | `FlowTriggerSubscriber` - the host-side multi-trigger bridge. |
| [`src/openhuman/flows/builder_tools.rs`](../../../src/openhuman/flows/builder_tools.rs) | The builder's `propose` / `revise` / `dry_run` / `save` / `run` tools. |
| [`src/openhuman/flows/agents/`](../../../src/openhuman/flows/agents/) | `workflow_builder` + `flow_discovery` agent definitions. |
| [`src/openhuman/tinyflows/langfuse_export.rs`](../../../src/openhuman/tinyflows/langfuse_export.rs) | Post-run export of the durable graph observations. |
## See also
- [Agent Harness](agent-harness.md) - the tinyagents turn every `agent` node (and
the builder/scout) runs on.
- [Security (`src/openhuman/security/`)](security.md) - the `SecurityPolicy` /
autonomy tier the outer node gate consults.
- [Architecture overview](README.md) - where flows sit in the bigger picture.
+3 -1
View File
@@ -20,7 +20,9 @@ You don't drag boxes to get started. Describe the automation in chat, for exampl
Two design guarantees make this safe:
* The `propose_workflow` tool **only validates and describes** a candidate graph. It can never create or enable a flow by itself.
* The **only** path from a proposal to a saved workflow is you clicking **Save & enable** on the card. That calls the `flows_create` RPC directly from the app, not from the agent.
* The **only** path from a proposal to a *new* saved workflow is you clicking **Save & enable** on the card. That calls the `flows_create` RPC directly from the app, not from the agent.
One deliberate carve-out: when *you* start the build (the Workflows page prompt bar creates the flow first and opens the copilot on it), the builder agent may finish the job with its `save_workflow` tool — it writes the built graph onto that **already-existing** flow after a sandbox dry run. It still cannot create a flow of its own, enable or disable one, or change the approval gate, and a real test run always requires your explicit confirmation first.
## What a workflow is made of
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# Live demo of the flows agents + Opus/Sonnet demo workflow against a real
# backend. Drives the whole arc end-to-end:
# flows_discover → flows_build → flows_create → flows_run
# and prints each step's output + the managed tier each agent node used.
#
# This runs the #[ignore]d `live_flows_demo_discover_build_save_run` test, which
# needs real credentials. It reads them from your `.env` (via load-dotenv.sh) or
# the ambient environment:
# OPENHUMAN_LIVE_API_URL backend origin (e.g. https://api.example.com)
# OPENHUMAN_LIVE_TOKEN a valid user session JWT
# OPENHUMAN_LIVE_USER_ID the user id owning that session
# Optional:
# OPENHUMAN_LIVE_FLOWS_TOPIC research topic to run the demo flow on
#
# Usage:
# scripts/live-flows-demo.sh [path/to/.env]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# Load .env (default: repo-root .env) into the environment if present. Missing
# file is non-fatal — the vars may already be exported in the shell.
ENV_FILE="${1:-$ROOT_DIR/.env}"
if [[ -f "$ENV_FILE" ]]; then
# shellcheck source=/dev/null
source "$SCRIPT_DIR/load-dotenv.sh" "$ENV_FILE"
fi
: "${OPENHUMAN_LIVE_API_URL:?set OPENHUMAN_LIVE_API_URL (backend origin)}"
: "${OPENHUMAN_LIVE_TOKEN:?set OPENHUMAN_LIVE_TOKEN (user session JWT)}"
: "${OPENHUMAN_LIVE_USER_ID:?set OPENHUMAN_LIVE_USER_ID (user id)}"
echo "[live-flows-demo] backend=$OPENHUMAN_LIVE_API_URL user=$OPENHUMAN_LIVE_USER_ID"
# macOS Apple-Silicon whisper-rs/llama.cpp build workaround (see AGENTS.md).
export GGML_NATIVE="${GGML_NATIVE:-OFF}"
cd "$ROOT_DIR"
exec cargo test --manifest-path Cargo.toml --test live_flows_demo_e2e -- --ignored --nocapture
+24 -11
View File
@@ -304,8 +304,8 @@ pub const BUILTINS: &[BuiltinAgent] = &[
// persists or enables a flow. Deliberately narrow propose-or-read tool belt.
BuiltinAgent {
id: "workflow_builder",
toml: include_str!("workflow_builder/agent.toml"),
prompt_fn: super::workflow_builder::prompt::build,
toml: include_str!("../../flows/agents/workflow_builder/agent.toml"),
prompt_fn: crate::openhuman::flows::agents::workflow_builder::prompt::build,
graph_fn: None,
},
// Workflow-discovery specialist (the "Flow Scout"): reads the user's
@@ -316,8 +316,8 @@ pub const BUILTINS: &[BuiltinAgent] = &[
// picked suggestion into a real graph proposal.
BuiltinAgent {
id: "flow_discovery",
toml: include_str!("flow_discovery/agent.toml"),
prompt_fn: super::flow_discovery::prompt::build,
toml: include_str!("../../flows/agents/flow_discovery/agent.toml"),
prompt_fn: crate::openhuman::flows::agents::flow_discovery::prompt::build,
graph_fn: None,
},
];
@@ -990,18 +990,25 @@ mod tests {
fn workflow_builder_is_registered_worker_with_narrow_propose_or_read_scope() {
// Phase 5a/5b: the workflow-builder must be a Worker-tier leaf whose
// tool scope is EXACTLY the propose-or-read + Composio discovery/connect
// + confirmed test-run belt — no persistence (flows_create/update/
// set_enabled), no shell, no file writes, no channel sends, and no
// composio_execute. It can list toolkits/connections, raise the inline
// connect card, and `run_workflow` a flow the user already SAVED to test
// it (a real run the prompt gates behind user confirmation), but it can
// never persist/enable a flow or perform a raw integration action. This
// + confirmed test-run + save-onto-existing belt — no flow creation
// (flows_create/set_enabled), no shell, no file writes, no channel
// sends, and no composio_execute. It can list toolkits/connections,
// raise the inline connect card, `run_workflow` a flow the user already
// SAVED to test it (a real run the prompt gates behind user
// confirmation), and `save_workflow` a built graph onto a flow the host
// ALREADY created (the prompt bar's instant-create path) — but it can
// never create/enable a flow or perform a raw integration action. This
// pins the invariant in the agent definition itself, not just the tool
// implementations.
let def = find("workflow_builder");
assert_eq!(def.agent_tier, AgentTier::Worker);
assert_eq!(def.delegate_name.as_deref(), Some("build_workflow"));
assert_eq!(def.sandbox_mode, SandboxMode::None);
// Graph authoring is multi-step structured reasoning — reasoning tier.
assert!(
matches!(def.model, ModelSpec::Hint(ref h) if h == "reasoning"),
"workflow_builder should use the reasoning tier"
);
// Worker leaf: no onward delegation.
assert!(
def.subagents.is_empty(),
@@ -1012,11 +1019,13 @@ mod tests {
let expected = [
"propose_workflow",
"revise_workflow",
"save_workflow",
"list_flows",
"get_flow",
"get_flow_run",
"list_flow_connections",
"search_tool_catalog",
"list_agent_profiles",
"dry_run_workflow",
"run_workflow",
"composio_list_toolkits",
@@ -1034,7 +1043,11 @@ mod tests {
expected.len(),
"workflow_builder scope must be EXACTLY the propose-or-read belt (got {names:?})"
);
// Hard exclusions: nothing that persists, executes, or sends.
// Hard exclusions: nothing that creates/enables a flow,
// executes raw integration actions, or touches the host.
// (Persistence onto an EXISTING flow is the deliberate
// `save_workflow` carve-out above; raw `flows_update` — which
// could also rename/re-gate arbitrary flows — stays out.)
for forbidden in [
"flows_create",
"flows_update",
@@ -11,7 +11,6 @@ pub mod context_scout;
pub mod critic;
pub mod crypto_agent;
pub mod desktop_control_agent;
pub mod flow_discovery;
pub mod goals_agent;
pub mod help;
pub mod image_agent;
@@ -37,6 +36,5 @@ pub mod trigger_reactor;
pub mod trigger_triage;
pub mod video_agent;
pub mod vision_agent;
pub mod workflow_builder;
pub use loader::{load_builtins, validate_tier_hierarchy, BuiltinAgent, BUILTINS};
@@ -1 +0,0 @@
pub mod prompt;
+18
View File
@@ -0,0 +1,18 @@
//! Flow-domain agents: the specialist agents that author and discover flows.
//!
//! These are first-class built-in agents (registered in the global
//! `agent_registry` via [`crate::openhuman::agent_registry::agents::loader`]),
//! but their definitions live here — inside the flows high-level module —
//! alongside the flows data model, tools, and RPCs they operate on, so the whole
//! feature is one cohesive unit. The loader's `BUILTINS` slice points at these
//! modules by path (the same cross-module pattern `reasoning_agent` uses from
//! `orchestration/`), so registration stays centralized while ownership stays
//! with the domain.
//!
//! - [`workflow_builder`] — authors tinyflows [`WorkflowGraph`](tinyflows::model::WorkflowGraph)s
//! from natural language and returns a validated PROPOSAL (never persists).
//! - [`flow_discovery`] — the read-only "Flow Scout" that grounds buildable
//! automation ideas from the user's data.
pub mod flow_discovery;
pub mod workflow_builder;
@@ -1,12 +1,14 @@
id = "workflow_builder"
display_name = "Workflow Builder"
delegate_name = "build_workflow"
when_to_use = "Workflow authoring specialist — owns building tinyflows automation graphs from a natural-language description. Route any request to 'set up a workflow that…', 'automate…', 'when X happens do Y', 'build/create/edit an automation or flow', or to iterate on a proposed workflow here. It grounds nodes in real tool slugs + connections, can dry-run a draft in a sandbox to self-check, and returns a workflow PROPOSAL for the user to review and save — it NEVER creates, enables, or runs a real flow itself. Not for running an already-saved flow (that is a direct flows_run) and not for the legacy skill 'workflows'."
when_to_use = "Workflow authoring specialist — owns building tinyflows automation graphs from a natural-language description. Route any request to 'set up a workflow that…', 'automate…', 'when X happens do Y', 'build/create/edit an automation or flow', or to iterate on a proposed workflow here. It grounds nodes in real tool slugs + connections, dry-runs a draft in a sandbox to self-check, and returns a workflow PROPOSAL for review. When the host hands it an existing flow id it may also SAVE the built graph onto that flow (save_workflow) and, with the user's explicit confirmation, test-run it — but it can never create a new flow or enable/disable one. Not for running an already-saved flow (that is a direct flows_run) and not for the legacy skill 'workflows'."
temperature = 0.2
max_iterations = 12
iteration_policy = "extended"
max_result_chars = 12000
# No sandbox filter needed: every tool in scope is propose-or-read, and
# No sandbox filter needed: the belt is propose/read/dry-run plus two
# explicitly-bounded writes (save_workflow onto an existing flow;
# run_workflow of a saved flow behind a confirm-first prompt rule), and
# dry_run_workflow runs against tinyflows MOCK capabilities (no real effects).
sandbox_mode = "none"
omit_identity = true
@@ -18,28 +20,32 @@ omit_safety_preamble = true
omit_skills_catalog = true
[model]
# Structured authoring on the cheap, high-throughput worker tier.
hint = "burst"
# Graph authoring is multi-step structured reasoning (ground slugs, wire
# expressions, self-check a dry run, then save) — worth the reasoning tier.
hint = "reasoning"
[tools]
# DELIBERATELY NARROW: propose/revise (validate-only) + read (flows, runs,
# connections, tool catalog) + sandbox dry-run + Composio discovery/connect +
# a confirmed real test-run of a SAVED flow. NO shell, NO file writes, NO
# channel sends, NO composio_execute, and NO flows_create/update/set_enabled —
# the agent can never persist or enable a flow, or perform a real integration
# action directly. Composio access is limited to LISTING toolkits/connections
# and raising the inline CONNECT card (an approval-gated OAuth hand-off).
# a confirmed real test-run of a SAVED flow + save_workflow (persist a graph
# onto an EXISTING flow only). NO shell, NO file writes, NO channel sends, NO
# composio_execute, and NO flows_create/set_enabled — the agent can never
# create or enable a flow, or perform a real integration action directly.
# Composio access is limited to LISTING toolkits/connections and raising the
# inline CONNECT card (an approval-gated OAuth hand-off).
# `run_workflow` executes a flow the user has ALREADY saved to test it — a real
# run, but the prompt requires asking the user to confirm first, and the flow's
# own approval gate still pauses outbound-action nodes.
named = [
"propose_workflow",
"revise_workflow",
"save_workflow",
"list_flows",
"get_flow",
"get_flow_run",
"list_flow_connections",
"search_tool_catalog",
"list_agent_profiles",
"dry_run_workflow",
"run_workflow",
"composio_list_toolkits",
@@ -0,0 +1,305 @@
//! Server-side turn-prompt construction for the `workflow_builder` agent.
//!
//! This is the Rust home of what used to live in the frontend
//! (`app/src/lib/flows/workflowBuilderPrompt.ts`): the natural-language brief
//! that kicks off a builder turn. Moving it here makes the builder a
//! first-class backend agent — `flows::ops::flows_build` runs the agent
//! directly (like the Flow Scout), instead of the frontend crafting delegate
//! strings and relying on the chat orchestrator to route them.
//!
//! Persistence contract, unchanged: `create`/`revise`/`repair` ask for a
//! PROPOSAL only — saving stays behind the user's explicit action.
//! [`BuildMode::Build`] is the instant-create path (the host has already made
//! the blank flow), so its brief tells the agent to finish the job: build,
//! dry-run, and `save_workflow` onto that flow id. Enabling/disabling a flow is
//! never in scope here.
use serde::Deserialize;
use serde_json::Value;
/// Which authoring turn to run. Selects the leading directive + how the current
/// graph / context is injected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BuildMode {
/// First draft from a free-text description; returns a proposal only.
Create,
/// Iterative refine of the injected draft; returns the revised proposal.
Revise,
/// Diagnose a failed run and propose a corrected graph.
Repair,
/// Instant-create: the flow already exists (blank), so build → dry-run →
/// `save_workflow` onto `flow_id`, end to end.
Build,
}
/// A structured builder-turn request. Replaces the four ad-hoc prompt builders
/// the frontend used to assemble; the handler passes one of these and the
/// server renders the brief.
#[derive(Debug, Clone, Deserialize)]
pub struct BuilderRequest {
/// Which kind of turn to run.
pub mode: BuildMode,
/// The user's ask: the description (`create`/`build`) or the change
/// instruction (`revise`), or a short note (`repair`, optional).
#[serde(default)]
pub instruction: String,
/// The current draft graph, injected as context for `revise`/`repair`/`build`.
#[serde(default)]
pub graph: Option<Value>,
/// The saved flow's id (required for `build`; optional elsewhere so the
/// agent may `run_workflow` it to test after confirming).
#[serde(default)]
pub flow_id: Option<String>,
/// The failed run id (== thread id) for `repair`, so the agent can
/// `get_flow_run` it.
#[serde(default)]
pub run_id: Option<String>,
/// The run-level error message for `repair`, if known.
#[serde(default)]
pub error: Option<String>,
/// Node ids implicated in the failure, for `repair`, if known.
#[serde(default)]
pub failing_node_ids: Vec<String>,
}
impl BuilderRequest {
/// Validates a builder-turn request before prompt rendering.
///
/// [`BuildMode::Build`] acts on an existing saved flow — its brief tells the
/// agent to `save_workflow` onto `flow_id`. A missing or blank `flow_id`
/// would otherwise render `The flow's id is ``.` into the brief and let the
/// agent save onto nothing, so reject it here (the RPC path deserializes
/// `BuilderRequest` directly, where only `mode` is required).
pub fn validate(&self) -> Result<(), String> {
if self.mode == BuildMode::Build
&& self
.flow_id
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
{
return Err("flows_build: `flow_id` is required for build mode".to_string());
}
Ok(())
}
}
/// A leading directive that frames the turn's persistence contract.
const DIRECTIVE_PROPOSE: &str =
"Design a tinyflows automation and return a workflow proposal for me to review. \
Do not save, enable, or run anything.";
const DIRECTIVE_REVISE: &str = "Revise this tinyflows automation and return the revised proposal. Do not save \
unless I explicitly ask you to (when I do, use save_workflow on the saved flow id), and never enable or \
disable anything. You may run_workflow the SAVED flow to test it, but ONLY if I ask and only after you \
confirm with me first.";
const DIRECTIVE_BUILD_AND_SAVE: &str = "Build this tinyflows automation END-TO-END. The flow already exists \
(created blank just now) — design the graph, verify it with dry_run_workflow, return the workflow \
proposal, then SAVE it onto the flow id below with save_workflow. Do not enable or disable anything, and \
do not run_workflow a real test unless I explicitly confirm first. Tell me what you saved when you are done.";
/// Serialize a graph compactly for injection as agent context.
fn serialize_graph(graph: &Value) -> String {
serde_json::to_string(graph).unwrap_or_else(|_| "{}".to_string())
}
/// Renders the natural-language brief for a builder turn from a structured
/// request. This is the single server-side source of the builder's turn text.
#[must_use]
pub fn render_prompt(req: &BuilderRequest) -> String {
let instruction = req.instruction.trim();
match req.mode {
BuildMode::Create => {
format!("{DIRECTIVE_PROPOSE}\n\nBuild a workflow that does this:\n{instruction}")
}
BuildMode::Revise => {
let mut lines = vec![
DIRECTIVE_REVISE.to_string(),
String::new(),
"Here is the current workflow draft (tinyflows WorkflowGraph JSON):".to_string(),
"```json".to_string(),
req.graph
.as_ref()
.map(serialize_graph)
.unwrap_or_else(|| "{}".to_string()),
"```".to_string(),
];
if let Some(flow_id) = req.flow_id.as_deref().filter(|s| !s.is_empty()) {
lines.push(String::new());
lines.push(format!(
"This workflow is saved with flow id `{flow_id}` — if I ask you to run/test it, you \
may run_workflow that id, but confirm with me first."
));
}
lines.push(String::new());
lines.push("Revise it as follows and return the full revised proposal:".to_string());
lines.push(instruction.to_string());
lines.join("\n")
}
BuildMode::Build => {
let flow_id = req.flow_id.as_deref().unwrap_or("");
[
DIRECTIVE_BUILD_AND_SAVE,
"",
&format!("The flow's id is `{flow_id}`. Its current (blank) graph is:"),
"```json",
&req.graph
.as_ref()
.map(serialize_graph)
.unwrap_or_else(|| "{}".to_string()),
"```",
"",
"Build a workflow that does this:",
instruction,
]
.join("\n")
}
BuildMode::Repair => {
let run_id = req.run_id.as_deref().unwrap_or("(unknown)");
let mut parts = vec![
DIRECTIVE_PROPOSE.to_string(),
String::new(),
format!(
"A run of this workflow failed (run id: {run_id}). Read the run with get_flow_run, \
diagnose why it failed, and propose a fix."
),
];
if let Some(err) = req
.error
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
parts.push(String::new());
parts.push(format!("Run error: {err}"));
}
if !req.failing_node_ids.is_empty() {
parts.push(String::new());
parts.push(format!(
"Failing step node id(s): {}",
req.failing_node_ids.join(", ")
));
}
if let Some(graph) = req.graph.as_ref() {
parts.push(String::new());
parts.push(
"Here is the current workflow draft (tinyflows WorkflowGraph JSON):"
.to_string(),
);
parts.push("```json".to_string());
parts.push(serialize_graph(graph));
parts.push("```".to_string());
}
if !instruction.is_empty() {
parts.push(String::new());
parts.push(instruction.to_string());
}
parts.push(String::new());
parts.push("Return the full corrected proposal.".to_string());
parts.join("\n")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn req(mode: BuildMode) -> BuilderRequest {
BuilderRequest {
mode,
instruction: "email me a digest every morning".to_string(),
graph: None,
flow_id: None,
run_id: None,
error: None,
failing_node_ids: vec![],
}
}
#[test]
fn create_prompt_frames_propose_only() {
let p = render_prompt(&req(BuildMode::Create));
assert!(p.contains("Do not save, enable, or run"));
assert!(p.contains("email me a digest every morning"));
}
#[test]
fn revise_injects_graph_and_flow_id() {
let mut r = req(BuildMode::Revise);
r.instruction = "add a Slack step".into();
r.graph = Some(json!({ "nodes": [], "edges": [] }));
r.flow_id = Some("flow_42".into());
let p = render_prompt(&r);
assert!(p.contains("```json"));
assert!(p.contains("flow_42"));
assert!(p.contains("add a Slack step"));
}
#[test]
fn build_asks_to_save_onto_flow_id() {
let mut r = req(BuildMode::Build);
r.flow_id = Some("flow_9".into());
r.graph = Some(json!({ "nodes": [], "edges": [] }));
let p = render_prompt(&r);
assert!(p.contains("save_workflow"));
assert!(p.contains("flow_9"));
assert!(p.contains("END-TO-END"));
}
#[test]
fn repair_includes_run_id_error_and_failing_nodes() {
let mut r = req(BuildMode::Repair);
r.run_id = Some("run_7".into());
r.error = Some("tool_call node: missing `slug`".into());
r.failing_node_ids = vec!["send".into(), "notify".into()];
r.graph = Some(json!({ "nodes": [], "edges": [] }));
let p = render_prompt(&r);
assert!(p.contains("run_7"));
assert!(p.contains("get_flow_run"));
assert!(p.contains("missing `slug`"));
assert!(p.contains("send, notify"));
}
#[test]
fn build_mode_deserializes_from_snake_case() {
let r: BuilderRequest =
serde_json::from_value(json!({ "mode": "build", "instruction": "x", "flow_id": "f1" }))
.expect("deserialize");
assert_eq!(r.mode, BuildMode::Build);
assert_eq!(r.flow_id.as_deref(), Some("f1"));
}
#[test]
fn validate_rejects_build_without_flow_id() {
// Missing entirely.
let missing = req(BuildMode::Build);
assert!(missing.validate().is_err());
// Present but blank / whitespace-only.
let mut blank = req(BuildMode::Build);
blank.flow_id = Some(" ".into());
assert!(blank.validate().is_err());
// A real id passes.
let mut ok = req(BuildMode::Build);
ok.flow_id = Some("flow_9".into());
assert!(ok.validate().is_ok());
}
#[test]
fn validate_allows_non_build_modes_without_flow_id() {
// Only `build` requires a flow id; the propose/revise/repair turns may run
// without one.
for mode in [BuildMode::Create, BuildMode::Revise, BuildMode::Repair] {
assert!(
req(mode).validate().is_ok(),
"{mode:?} should not require flow_id"
);
}
}
}
@@ -0,0 +1,2 @@
pub mod builder_prompt;
pub mod prompt;
@@ -6,20 +6,42 @@ Slack", "when a new Stripe payment arrives, add a row to my sheet") into a
concrete **tinyflows `WorkflowGraph`** and returns it as a *proposal* for the
user to review and save.
## The one invariant you must never break: propose, never persist
## The invariants you must never break
You **cannot and must not** create, update, enable, or disable a saved flow. You
have no tool that does — by design. Your authoring outputs are:
You **cannot and must not** create a new flow, or enable/disable one. You have
no tool that does — by design. Your authoring outputs are:
- **`propose_workflow`** / **`revise_workflow`** — these *validate* a candidate
graph and hand back a proposal summary. They **never** save anything.
- **`dry_run_workflow`** — runs a *draft* in a **sandbox** against mock
capabilities (deterministic echoes). Nothing real happens: no message is sent,
no code runs, no HTTP fires. Treat its output as a wiring check only.
- **`save_workflow`** — the ONE persistence tool you have, and it only writes to
a flow that **already exists** (you need its `flow_id`). See below.
Only the user's own "Save & enable" click in the review card persists a flow
(via `flows_create`, which re-validates server-side). If a user says "just turn
it on for me", explain that you can only propose it — they confirm the save.
If there is no existing flow to save to, only the user's own "Save & enable"
click in the review card persists a flow (via `flows_create`, which
re-validates server-side). If a user says "just turn it on for me", explain
that enabling stays in their hands.
## Saving your work: `save_workflow` (finish the job — don't hand it back)
When the request gives you a **flow id to build into** (the Flows page's prompt
bar creates the flow first and delegates with its id; the canvas copilot passes
the saved flow's id), the user expects you to **finish**: build, verify, and
**save** — not to tell them to go save it themselves. The arc:
1. Ground + build the graph (below), `dry_run_workflow` until it's clean.
2. `revise_workflow` / `propose_workflow` so the user gets the reviewable
proposal card.
3. **`save_workflow { flow_id, graph, name? }`** to persist it onto that flow,
then tell the user plainly what you saved (trigger, steps, and — if the flow
is enabled with a schedule/app_event trigger — that it is now live and will
fire on its own).
Never `save_workflow` onto a flow the user did NOT ask you to build/update —
editing some other saved flow requires their explicit ask naming it. It cannot
create flows, and it never changes `enabled` or the approval gate.
## Testing a saved flow: `run_workflow` (ask first!)
@@ -28,9 +50,10 @@ end-to-end. Unlike `dry_run_workflow`, this is a **real run** — real effects c
fire (the flow's own approval gate still pauses outbound-action nodes, but treat
it as real). Rules:
1. **Only a saved flow.** `run_workflow` needs a `flow_id`; if the workflow isn't
saved yet, tell the user to Save it first (you can't run a draft — use
`dry_run_workflow` for a draft wiring check).
1. **Only a saved flow.** `run_workflow` needs a `flow_id`; if the graph isn't
saved yet, save it first (`save_workflow` when you have the flow id,
otherwise the user's Save click). You can't run a draft — use
`dry_run_workflow` for a draft wiring check.
2. **ALWAYS ask for confirmation and wait for an explicit "yes"** before calling
`run_workflow`. Say what it will do ("This will run the flow for real and may
send/act on live data — run it now?") and only proceed once they agree. Never
@@ -75,9 +98,8 @@ user to go do it elsewhere:
`connection_ref` and put it on the node.
Still bounded: you can **discover and connect** apps, but you have **no** tool to
*execute* a Composio action (`composio_execute` is deliberately out of scope) and
**no** tool to persist a flow. Connecting is a setup step in service of a
proposal — the user still saves the workflow themselves.
*execute* a Composio action (`composio_execute` is deliberately out of scope).
Connecting is a setup step in service of the workflow you were asked to build.
Typical setup arc: user asks for a Slack step → `composio_list_connections`
shows Slack isn't linked → `composio_connect { toolkit: "slack" }` → once
+249 -8
View File
@@ -12,15 +12,19 @@
//! | [`GetFlowRunTool`] | `None` | read: fetch a run's steps |
//! | [`ListFlowConnectionsTool`] | `None` | read: connection refs (ids/names only) |
//! | [`SearchToolCatalogTool`] | `None` | read: real Composio tool slugs |
//! | [`ListAgentProfilesTool`] | `None` | read: selectable agent kinds (`agent_ref`)|
//! | [`DryRunWorkflowTool`] | `Execute` (tier-gated) | run a *draft* against MOCK capabilities |
//! | [`SaveWorkflowTool`] | `Write` | persist a graph onto an EXISTING flow |
//!
//! **Human-in-the-loop invariant (shared with [`super::tools::ProposeWorkflowTool`]):**
//! nothing here EVER persists or enables a flow. `revise_workflow` only
//! validates and returns a proposal payload (identical contract to
//! `propose_workflow`); the read tools are pure reads; `dry_run_workflow`
//! executes against `tinyflows`' deterministic **mock** capabilities so no real
//! LLM / tool / HTTP / code side effect can fire. Only the user's own
//! "Save & enable" click (→ `openhuman.flows_create`) writes anything.
//! **Human-in-the-loop invariant (shared with [`super::tools::ProposeWorkflowTool`]),
//! with one deliberate carve-out:** `revise_workflow` only validates and
//! returns a proposal payload (identical contract to `propose_workflow`); the
//! read tools are pure reads; `dry_run_workflow` executes against `tinyflows`'
//! deterministic **mock** capabilities so no real LLM / tool / HTTP / code side
//! effect can fire. The carve-out is [`SaveWorkflowTool`]: it persists a graph
//! onto a flow that ALREADY exists (the Flows prompt bar's instant-create path
//! makes the flow first and hands the agent its id) — but the agent still
//! cannot *create* a flow, and never touches `enabled`/`require_approval`.
//!
//! The agent's full tool scope (see `agent_registry/agents/workflow_builder/
//! agent.toml`) also grants the Composio **discovery/connect** tools —
@@ -608,6 +612,90 @@ impl Tool for SearchToolCatalogTool {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// list_agent_profiles — read-only: selectable agent kinds for an `agent` node
// ─────────────────────────────────────────────────────────────────────────────
/// `list_agent_profiles`: read-only listing of the agent **kinds** an `agent`
/// node can select via `agent_ref` (researcher, code_executor, crypto_agent, …).
///
/// Grounds the builder's `agent_ref` choice in real registry ids — the agent
/// analogue of `search_tool_catalog` for `tool_call` slugs — so it never
/// hallucinates an agent kind. Returns `{ id, name, description, model, tools,
/// tags }` for every enabled registered agent.
pub struct ListAgentProfilesTool;
impl ListAgentProfilesTool {
/// Builds the tool (no configuration — reads the process-global registry).
#[must_use]
pub fn new() -> Self {
Self
}
}
impl Default for ListAgentProfilesTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for ListAgentProfilesTool {
fn name(&self) -> &str {
"list_agent_profiles"
}
fn description(&self) -> &str {
"List the agent KINDS an `agent` node can run via its `agent_ref` config \
field (e.g. researcher, code_executor, crypto_agent). Read-only. Returns \
a JSON array of { id, name, description, model, tools, tags }. Use this to \
pick a real agent_ref — a coding step should reference the coding agent, a \
research step the researcher — instead of guessing an id. Note: an \
agent_ref applies that agent's persona/model to the step; its private \
tool loop is a follow-up, so a step still gets tools from the node's own \
inline `tools` list for now."
}
fn parameters_schema(&self) -> Value {
json!({ "type": "object", "properties": {}, "additionalProperties": false })
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::None
}
fn external_effect(&self) -> bool {
false
}
async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
tracing::debug!(target: "flows", "[flows] list_agent_profiles: listing registered agent kinds (read-only)");
match crate::openhuman::agent_registry::list_agents(false).await {
Ok(agents) => {
let profiles: Vec<Value> = agents
.iter()
.map(|a| {
json!({
"id": a.id,
"name": a.name,
"description": a.description,
"model": a.model,
"tools": a.tool_allowlist,
"tags": a.tags,
})
})
.collect();
Ok(ToolResult::success(serde_json::to_string_pretty(
&json!({ "agent_profiles": profiles }),
)?))
}
Err(e) => Ok(ToolResult::error(format!(
"Failed to list agent profiles: {e}"
))),
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// dry_run_workflow — execute a DRAFT against MOCK capabilities (tier-gated)
// ─────────────────────────────────────────────────────────────────────────────
@@ -737,7 +825,15 @@ impl Tool for DryRunWorkflowTool {
}
};
let mut caps = tinyflows::caps::mock::mock_capabilities();
// Wire the mock `AgentRunner` (echoes `agent_ref`/request/conn) so a
// draft with `agent` nodes exercises the agent-node path during the
// dry run instead of erroring on a missing capability — the plain
// `mock_capabilities()` leaves `agent: None`. No real agent turn fires;
// the mock runner is a deterministic echo, same contract as the other
// sandbox mocks.
let mut caps = tinyflows::caps::mock::mock_capabilities_with_agent(
tinyflows::caps::mock::MockAgentRunner,
);
// Wiring preflight over the echo mocks (see the struct doc): required
// Composio args must be present and non-null even in the sandbox.
caps.tools = std::sync::Arc::new(crate::openhuman::tinyflows::caps::PreflightToolInvoker {
@@ -785,6 +881,151 @@ impl Tool for DryRunWorkflowTool {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// save_workflow — persist a built graph onto an EXISTING saved flow
// ─────────────────────────────────────────────────────────────────────────────
/// `save_workflow`: persist a validated graph (and optionally a new name) onto
/// an **existing, already-saved** flow via [`ops::flows_update`] — the same
/// validate-and-migrate path the UI's Save uses.
///
/// This is the deliberate, narrow exception to the belt's original
/// "propose, never persist" invariant (added for the Flows prompt bar's
/// instant-create path, where the host creates the flow *before* delegating and
/// hands the agent its `flow_id`). The boundaries that remain:
///
/// - **Update-only.** It requires an existing `flow_id`; there is still no tool
/// to *create* a flow, so the agent can only write where the host (or user)
/// already made a flow.
/// - **Never touches enablement or the approval gate.** `enabled` and
/// `require_approval` are not parameters; whatever the user set stays.
/// - **Real persistence, real consequences.** Saving a `schedule`/`app_event`
/// trigger onto an ENABLED flow arms it (the trigger binds and will fire on
/// its own) — hence `PermissionLevel::Write`. The description tells the agent
/// to dry-run first and to say what it saved.
pub struct SaveWorkflowTool {
config: Arc<Config>,
}
impl SaveWorkflowTool {
pub fn new(config: Arc<Config>) -> Self {
Self { config }
}
}
#[async_trait]
impl Tool for SaveWorkflowTool {
fn name(&self) -> &str {
"save_workflow"
}
fn description(&self) -> &str {
"Save a workflow graph onto an EXISTING saved flow (by `flow_id`), persisting it. \
Use this after the user asked you to build/update a workflow and you have \
dry-run-verified the graph: it validates and writes the graph (and optional new \
`name`) to that flow. It can NOT create a new flow, and it never changes the \
flow's enabled state or its approval gate. NOTE: if the flow is enabled and the \
graph has a schedule/app_event trigger, saving arms it — it will start firing on \
its own. Always tell the user what you saved. Params: { flow_id, graph, name? }."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"flow_id": {
"type": "string",
"description": "Id of the EXISTING saved flow to write the graph to."
},
"graph": {
"type": "object",
"description": "The full tinyflows WorkflowGraph to persist: { name?, nodes: [...], edges: [...] }. Same shape as propose_workflow.",
"properties": {
"nodes": { "type": "array" },
"edges": { "type": "array" }
},
"required": ["nodes", "edges"]
},
"name": {
"type": "string",
"description": "Optional new human-readable name for the flow."
}
},
"required": ["flow_id", "graph"],
"additionalProperties": false
})
}
fn permission_level(&self) -> PermissionLevel {
// Persists a flow definition; on an enabled flow this can arm a
// self-firing trigger — gate like a Write-class action.
PermissionLevel::Write
}
fn external_effect(&self) -> bool {
// Persistence is local (no message/HTTP/code fires at save time); the
// flow's own runs — and their approval gate — govern real effects.
false
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let flow_id = match args.get("flow_id").and_then(Value::as_str).map(str::trim) {
Some(id) if !id.is_empty() => id.to_string(),
_ => {
return Ok(ToolResult::error(
"Missing 'flow_id' — save_workflow only updates an EXISTING saved flow. \
If there is no flow yet, return the proposal and let the user save it."
.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 name = args
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
tracing::info!(
target: "flows",
%flow_id,
renaming = name.is_some(),
"[flows] save_workflow: agent-initiated save to existing flow"
);
match ops::flows_update(&self.config, &flow_id, name, Some(graph_json), None).await {
Ok(outcome) => {
let flow = outcome.value;
tracing::info!(
target: "flows",
%flow_id,
node_count = flow.graph.nodes.len(),
enabled = flow.enabled,
"[flows] save_workflow: persisted"
);
Ok(ToolResult::success(serde_json::to_string_pretty(&json!({
"type": "workflow_saved",
"flow_id": flow.id,
"name": flow.name,
"enabled": flow.enabled,
"require_approval": flow.require_approval,
"node_count": flow.graph.nodes.len(),
}))?))
}
Err(e) => {
tracing::debug!(target: "flows", %flow_id, error = %e, "[flows] save_workflow: failed");
Ok(ToolResult::error(format!(
"Could not save workflow to flow '{flow_id}': {e}"
)))
}
}
}
}
#[cfg(test)]
#[path = "builder_tools_tests.rs"]
mod tests;
+139
View File
@@ -246,6 +246,39 @@ async fn dry_run_supervised_runs_against_mock_and_labels_sandbox() {
.contains("sandbox"));
}
#[tokio::test]
async fn dry_run_exercises_agent_ref_node_via_mock_agent_runner() {
// A draft whose `agent` node selects a named agent kind (`agent_ref`) routes
// to the `AgentRunner` capability, not the plain LLM. Before wiring the mock
// runner the sandbox left `agent: None`, so such a draft errored on a missing
// capability; now `mock_capabilities_with_agent(MockAgentRunner)` echoes the
// ref and the dry run goes green — proving the builder can self-test drafts
// that use agent-kind nodes.
let tool = DryRunWorkflowTool::new(
policy(AutonomyLevel::Supervised),
test_config(&TempDir::new().unwrap()),
);
let graph = json!({
"nodes": [
{ "id": "t", "kind": "trigger", "name": "Manual" },
{ "id": "a", "kind": "agent", "name": "Plan",
"config": { "agent_ref": "researcher", "prompt": "outline it" } }
],
"edges": [ { "from_node": "t", "to_node": "a" } ]
});
let result = tool
.execute(json!({ "graph": graph, "input": { "topic": "x" } }))
.await
.unwrap();
assert!(!result.is_error, "{}", result.output());
let parsed: Value = serde_json::from_str(&result.output()).unwrap();
assert_eq!(parsed["sandbox"], true);
assert_eq!(
parsed["ok"], true,
"agent_ref dry-run must be green: {parsed}"
);
}
#[tokio::test]
async fn dry_run_invalid_graph_is_error() {
let tool = DryRunWorkflowTool::new(
@@ -364,3 +397,109 @@ async fn revise_workflow_warns_on_unwired_required_composio_arg() {
"wired arg must not warn: {warnings:?}"
);
}
// ── save_workflow ────────────────────────────────────────────────────────────
/// Seed a saved flow to write into (the instant-create path does this via
/// `flows_create` before delegating to the builder).
async fn seed_flow(config: &Arc<Config>, name: &str) -> String {
let outcome = ops::flows_create(
config,
name.to_string(),
json!({
"nodes": [ { "id": "t", "kind": "trigger", "name": "Manual" } ],
"edges": []
}),
true,
)
.await
.unwrap();
outcome.value.id
}
#[tokio::test]
async fn save_workflow_missing_flow_id_is_error() {
let tmp = TempDir::new().unwrap();
let tool = SaveWorkflowTool::new(test_config(&tmp));
// Persisting a definition is a Write-class action (no external effect at
// save time — the flow's own runs govern that).
assert_eq!(tool.permission_level(), PermissionLevel::Write);
assert!(!tool.external_effect());
let result = tool
.execute(json!({ "graph": valid_graph() }))
.await
.unwrap();
assert!(result.is_error);
assert!(result.output().contains("Missing 'flow_id'"));
}
#[tokio::test]
async fn save_workflow_unknown_flow_is_error() {
let tmp = TempDir::new().unwrap();
let tool = SaveWorkflowTool::new(test_config(&tmp));
let result = tool
.execute(json!({ "flow_id": "nope", "graph": valid_graph() }))
.await
.unwrap();
assert!(result.is_error, "save onto a nonexistent flow must fail");
assert!(result.output().contains("nope"));
}
#[tokio::test]
async fn save_workflow_persists_graph_and_name_onto_existing_flow() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let flow_id = seed_flow(&config, "Blank flow").await;
let tool = SaveWorkflowTool::new(config.clone());
let result = tool
.execute(json!({
"flow_id": flow_id,
"graph": valid_graph(),
"name": "AI News Digest"
}))
.await
.unwrap();
assert!(!result.is_error, "{}", result.output());
let parsed: Value = serde_json::from_str(&result.output()).unwrap();
assert_eq!(parsed["type"], "workflow_saved");
assert_eq!(parsed["flow_id"], flow_id.as_str());
assert_eq!(parsed["name"], "AI News Digest");
assert_eq!(parsed["node_count"], 2);
// Enablement / approval gate are NOT touched by the tool.
assert_eq!(parsed["require_approval"], true);
// The graph + name really persisted.
let saved = ops::flows_get(&config, &flow_id).await.unwrap().value;
assert_eq!(saved.name, "AI News Digest");
assert_eq!(saved.graph.nodes.len(), 2);
}
#[tokio::test]
async fn save_workflow_rejects_invalid_graph_and_leaves_flow_intact() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let flow_id = seed_flow(&config, "Blank flow").await;
let tool = SaveWorkflowTool::new(config.clone());
let result = tool
.execute(json!({
"flow_id": flow_id,
// No trigger node — fails tinyflows validation.
"graph": { "nodes": [ { "id": "a", "kind": "agent", "name": "A" } ], "edges": [] }
}))
.await
.unwrap();
assert!(result.is_error);
let saved = ops::flows_get(&config, &flow_id).await.unwrap().value;
assert_eq!(saved.name, "Blank flow");
assert_eq!(
saved.graph.nodes.len(),
1,
"original graph must be untouched"
);
}
+1
View File
@@ -7,6 +7,7 @@
//! [`crate::openhuman::tinyflows::caps::FlowStateStore`]); the RPC/CLI
//! controller surface in `schemas` (private, re-exported below).
pub mod agents;
pub mod builder_tools;
pub mod bus;
pub mod discovery_tools;
+341 -8
View File
@@ -1687,6 +1687,139 @@ const FLOW_DISCOVER_PROMPT: &str = "Discover the most useful automations you cou
deal with, and the flows I already have — then propose a few concrete, buildable workflows. \
Ground each in something you actually observed about me, and end by calling suggest_workflows.";
// ─────────────────────────────────────────────────────────────────────────────
// Copilot / scout streaming (Phase B) — bridge a builder/scout turn's live
// AgentProgress onto the web-channel socket, keyed by a chat thread, exactly
// like an interactive chat turn. Blueprint: `agent/task_dispatcher/executor.rs`.
// ─────────────────────────────────────────────────────────────────────────────
/// Where to stream a `flows_build` / `flows_discover` turn. When present, the
/// agent's progress events (`text_delta` / `thinking_delta` / `tool_call` /
/// `tool_result` / terminal `chat_done`) are published as `WebChannelEvent`s
/// tagged with this `thread_id` — the same room the shared chat pane already
/// subscribes to and decodes — so the copilot/scout UI renders streamed text,
/// tool cards, and workflow-proposal cards live instead of spinning for the
/// whole (up to 300s) headless run.
///
/// Broadcast client id is always `"system"` (like cron / task-session runs), so
/// any client viewing the thread receives the events (the frontend keys by
/// `thread_id`). The blocking `{ proposal, assistant_text }` return is
/// unchanged — streaming is purely additive, opt-in per call.
#[derive(Debug, Clone)]
pub struct FlowStreamTarget {
/// The chat thread the copilot/scout turn streams into.
pub thread_id: String,
/// Per-turn correlation id (matches the frontend `request_id`). Generated
/// when the caller doesn't supply one.
pub request_id: String,
}
impl FlowStreamTarget {
/// Build a streaming target from optional RPC params. Streaming is enabled
/// only when a non-empty `thread_id` is given; a missing/blank `request_id`
/// is filled with a fresh uuid so the turn is always correlatable. Returns
/// `None` (headless run, prior behaviour) when no usable `thread_id`.
pub fn from_params(thread_id: Option<String>, request_id: Option<String>) -> Option<Self> {
let thread_id = thread_id
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())?;
let request_id = request_id
.map(|r| r.trim().to_string())
.filter(|r| !r.is_empty())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
Some(Self {
thread_id,
request_id,
})
}
}
/// Attach the web-channel progress bridge to `agent` for a builder/scout turn.
/// Wires an mpsc channel into the agent's progress sink and spawns the bridge
/// task that translates each [`AgentProgress`] into a socket event keyed by the
/// target thread (and mirrors a `TurnStateStore` so the tool timeline replays
/// on reopen). The bridge task lives until the agent drops its progress sender
/// (turn end). `source` is a short trace-attribution label (e.g.
/// `"flows_build"`).
fn attach_flow_progress_bridge(
agent: &mut crate::openhuman::agent::Agent,
target: &FlowStreamTarget,
source: &str,
config: &Config,
) {
let (progress_tx, progress_rx) = tokio::sync::mpsc::channel(64);
agent.set_on_progress(Some(progress_tx));
tracing::info!(
target: "flows",
thread_id = %target.thread_id,
request_id = %target.request_id,
source = %source,
"[flows] progress bridge: attaching (streaming copilot/scout turn)"
);
crate::openhuman::channels::providers::web::spawn_progress_bridge(
progress_rx,
"system".to_string(),
target.thread_id.clone(),
target.request_id.clone(),
crate::openhuman::threads::turn_state::TurnStateStore::new(config.workspace_dir.clone()),
crate::openhuman::channels::providers::web::ChatRequestMetadata {
source: Some(source.to_string()),
..Default::default()
},
config.clone(),
);
}
/// Emit the terminal chat event a streamed builder/scout turn owes its viewers.
/// The progress bridge only streams intermediate deltas; without this the live
/// session spins forever. Mirrors how `task_dispatcher/executor.rs` finalizes a
/// streamed run: a success delivers a `chat_done` (via the shared presentation
/// path, so segmentation/reaction match a normal turn), a failure publishes a
/// `chat_error`. Broadcast as `"system"` so any viewer of the thread receives
/// it (frontend keys by `thread_id`).
async fn finalize_flow_stream(
target: &FlowStreamTarget,
result: &Result<String, String>,
prompt: &str,
) {
match result {
Ok(text) => {
crate::openhuman::channels::providers::web::presentation::deliver_response(
"system",
&target.thread_id,
&target.request_id,
text,
prompt,
&[],
// Builder/scout turns don't surface in the chat footer; their
// token/cost spend is still captured by the global cost tracker.
None,
)
.await;
}
Err(err) => {
crate::openhuman::channels::providers::web::publish_web_channel_event(
crate::core::socketio::WebChannelEvent {
event: "chat_error".to_string(),
client_id: "system".to_string(),
thread_id: target.thread_id.clone(),
request_id: target.request_id.clone(),
message: Some(err.clone()),
error_type: Some("agent_error".to_string()),
..Default::default()
},
);
}
}
tracing::info!(
target: "flows",
thread_id = %target.thread_id,
request_id = %target.request_id,
ok = result.is_ok(),
"[flows] progress bridge: detached (terminal chat event emitted)"
);
}
/// Runs the read-only `flow_discovery` agent ("Flow Scout") on demand: it reads
/// the user's memory/threads/people/connections/existing flows, grounds a few
/// automation ideas, and records them via the `suggest_workflows` tool (which
@@ -1697,11 +1830,18 @@ const FLOW_DISCOVER_PROMPT: &str = "Discover the most useful automations you cou
/// (`PermissionLevel::None`) — so this never persists, enables, or runs a flow.
/// Turning a suggestion into a real flow is the user's separate "Build this"
/// action, which routes to `workflow_builder`.
pub async fn flows_discover(config: &Config) -> Result<RpcOutcome<Vec<FlowSuggestion>>, String> {
pub async fn flows_discover(
config: &Config,
stream: Option<FlowStreamTarget>,
) -> Result<RpcOutcome<Vec<FlowSuggestion>>, String> {
use crate::openhuman::agent::turn_origin::{with_origin, AgentTurnOrigin};
use crate::openhuman::agent::Agent;
tracing::info!(target: "flows", "[flows] flows_discover: starting Flow Scout discovery run");
tracing::info!(
target: "flows",
streaming = stream.is_some(),
"[flows] flows_discover: starting Flow Scout discovery run"
);
// The registry must be initialised before building a named builtin agent
// (mirrors `agent_registry::ops::available_tools`); it is idempotent, so a
@@ -1713,24 +1853,46 @@ pub async fn flows_discover(config: &Config) -> Result<RpcOutcome<Vec<FlowSugges
.map_err(|e| format!("failed to build flow_discovery agent: {e:#}"))?;
agent.set_agent_definition_name("flow_discovery".to_string());
// When a chat thread is attached, stream the scout turn into it exactly like
// an interactive turn (see `FlowStreamTarget`). Best-effort — with no target
// the run stays headless, exactly as before.
if let Some(target) = &stream {
attach_flow_progress_bridge(&mut agent, target, "flows_discover", config);
}
// Run to completion under a CLI origin (an internal, user-initiated action —
// the approval gate must not fail-closed on it), bounded by a wall-clock
// timeout so a hung provider call can't wedge the RPC.
// timeout so a hung provider call can't wedge the RPC. When streaming, the
// run is wrapped in the thread-id scope so descendant turns tag their trace
// and socket events with this thread.
let run = with_origin(AgentTurnOrigin::Cli, agent.run_single(FLOW_DISCOVER_PROMPT));
match tokio::time::timeout(
let run = tokio::time::timeout(
std::time::Duration::from_secs(FLOW_DISCOVER_TIMEOUT_SECS),
run,
)
.await
{
Ok(Ok(_summary)) => {
);
let timed = match &stream {
Some(target) => {
crate::openhuman::inference::provider::thread_context::with_thread_id(
target.thread_id.clone(),
run,
)
.await
}
None => run.await,
};
// Reduce the (timeout, run) result to a single `Result<summary, error>` so
// the terminal chat event can be emitted uniformly for the streamed case.
let outcome: Result<String, String> = match timed {
Ok(Ok(summary)) => {
tracing::debug!(target: "flows", "[flows] flows_discover: agent run completed");
Ok(summary)
}
Ok(Err(e)) => {
// The agent errored. Surface it, but still return whatever
// suggestions may already be persisted (a prior run's active set)
// rather than hard-failing the UI.
tracing::warn!(target: "flows", error = %e, "[flows] flows_discover: agent run failed");
Err(format!("flow_discovery run failed: {e:#}"))
}
Err(_) => {
tracing::warn!(
@@ -1738,7 +1900,16 @@ pub async fn flows_discover(config: &Config) -> Result<RpcOutcome<Vec<FlowSugges
timeout_secs = FLOW_DISCOVER_TIMEOUT_SECS,
"[flows] flows_discover: agent run timed out"
);
Err(format!(
"flow_discovery run timed out after {FLOW_DISCOVER_TIMEOUT_SECS}s"
))
}
};
// Emit the terminal chat event so a client viewing the thread finalizes the
// assistant bubble instead of spinning (the bridge only streams deltas).
if let Some(target) = &stream {
finalize_flow_stream(target, &outcome, FLOW_DISCOVER_PROMPT).await;
}
let suggestions = store::list_suggestions(config, Some(SuggestionStatus::New), 50)
@@ -1754,6 +1925,168 @@ pub async fn flows_discover(config: &Config) -> Result<RpcOutcome<Vec<FlowSugges
))
}
/// Overall safety bound on one `flows_build` run. The `workflow_builder` agent's
/// own `max_iterations` caps its loop, but a hung LLM/tool call must never let
/// the RPC block indefinitely.
const FLOW_BUILD_TIMEOUT_SECS: u64 = 300;
/// Runs the `workflow_builder` agent for one authoring turn and returns its
/// proposal, invoking it as a first-class backend agent (exactly like the Flow
/// Scout `flows_discover`) rather than routing a hand-crafted delegate prompt
/// through the chat orchestrator.
///
/// The turn's natural-language brief is rendered **server-side** from the
/// structured [`BuilderRequest`](crate::openhuman::flows::agents::workflow_builder::builder_prompt::BuilderRequest)
/// (create / revise / repair / build). The agent ends by calling
/// `propose_workflow` / `revise_workflow` / `save_workflow`; we capture the
/// resulting `{ type: "workflow_proposal", … }` payload from the run's tool
/// history and return it alongside the agent's final assistant text.
///
/// Persistence stays with the agent's tools: `propose`/`revise` never persist;
/// `save_workflow` (only reachable in `build` mode with a real `flow_id`)
/// writes onto an existing flow. This op never enables or runs a flow.
pub async fn flows_build(
config: &Config,
req: crate::openhuman::flows::agents::workflow_builder::builder_prompt::BuilderRequest,
stream: Option<FlowStreamTarget>,
) -> Result<RpcOutcome<Value>, String> {
use crate::openhuman::agent::Agent;
use crate::openhuman::flows::agents::workflow_builder::builder_prompt::render_prompt;
// Reject invalid turns (e.g. a `build` with no `flow_id`) before we render a
// brief that would tell the agent to save onto nothing.
req.validate()?;
let prompt = render_prompt(&req);
tracing::info!(
target: "flows",
mode = ?req.mode,
has_graph = req.graph.is_some(),
flow_id = req.flow_id.as_deref().unwrap_or("<none>"),
streaming = stream.is_some(),
"[flows] flows_build: starting workflow_builder turn"
);
// The registry must be initialised before building a named builtin agent
// (idempotent — mirrors `flows_discover`).
crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global(&config.workspace_dir)
.map_err(|e| format!("failed to initialise agent registry: {e}"))?;
let mut agent = Agent::from_config_for_agent(config, "workflow_builder")
.map_err(|e| format!("failed to build workflow_builder agent: {e:#}"))?;
agent.set_agent_definition_name("workflow_builder".to_string());
// When a chat thread is attached (the copilot pane), stream the builder turn
// into it exactly like an interactive turn — text/tool deltas and the
// `propose_workflow` tool result the frontend renders as a proposal card.
// Best-effort — with no target the run stays headless (CLI / tests).
if let Some(target) = &stream {
attach_flow_progress_bridge(&mut agent, target, "flows_build", config);
}
// Run to completion under a CLI origin (internal, user-initiated — the
// approval gate must not fail-closed), bounded by a wall-clock timeout. When
// streaming, wrap the run in the thread-id scope so descendant turns tag
// their trace + socket events with this thread.
let run = with_origin(AgentTurnOrigin::Cli, agent.run_single(&prompt));
let run = tokio::time::timeout(std::time::Duration::from_secs(FLOW_BUILD_TIMEOUT_SECS), run);
let timed = match &stream {
Some(target) => {
crate::openhuman::inference::provider::thread_context::with_thread_id(
target.thread_id.clone(),
run,
)
.await
}
None => run.await,
};
let (assistant_text, run_error) = match timed {
Ok(Ok(text)) => (text, None),
Ok(Err(e)) => {
tracing::warn!(target: "flows", error = %e, "[flows] flows_build: agent run failed");
(
String::new(),
Some(format!("workflow_builder run failed: {e:#}")),
)
}
Err(_) => {
tracing::warn!(
target: "flows",
timeout_secs = FLOW_BUILD_TIMEOUT_SECS,
"[flows] flows_build: agent run timed out"
);
(
String::new(),
Some(format!(
"workflow_builder run timed out after {FLOW_BUILD_TIMEOUT_SECS}s"
)),
)
}
};
// Emit the terminal chat event so a client viewing the copilot thread stops
// "processing" and finalizes the assistant bubble (the bridge streams only
// intermediate deltas). Success delivers `chat_done`; a run error delivers
// `chat_error`. The blocking return below is unchanged.
if let Some(target) = &stream {
let terminal: Result<String, String> = match &run_error {
None => Ok(assistant_text.clone()),
Some(err) => Err(err.clone()),
};
finalize_flow_stream(target, &terminal, &prompt).await;
}
// Capture the proposal from the run's tool history (propose/revise/save all
// emit the same self-describing `{ type: "workflow_proposal", … }` payload).
let proposal = extract_workflow_proposal(agent.history());
// A run that both errored AND produced no proposal is a hard failure; a run
// that proposed before erroring still returns the proposal for review.
if proposal.is_none() {
if let Some(err) = &run_error {
return Err(format!("workflow_builder produced no proposal: {err}"));
}
}
tracing::info!(
target: "flows",
has_proposal = proposal.is_some(),
"[flows] flows_build: workflow_builder turn complete"
);
Ok(RpcOutcome::single_log(
json!({
"proposal": proposal,
"assistant_text": assistant_text,
"error": run_error,
}),
"workflow builder turn complete",
))
}
/// Scans an agent run's conversation history for the workflow proposal a builder
/// tool emitted. `propose_workflow` / `revise_workflow` / `save_workflow` all
/// return a self-describing `{ "type": "workflow_proposal", … }` JSON string as
/// their tool result, so we match on that (the same gate the frontend uses) and
/// return the LAST one — the most recent proposal in the turn.
fn extract_workflow_proposal(
history: &[crate::openhuman::inference::provider::ConversationMessage],
) -> Option<Value> {
use crate::openhuman::inference::provider::ConversationMessage;
let mut latest = None;
for message in history {
if let ConversationMessage::ToolResults(results) = message {
for result in results {
if let Ok(value) = serde_json::from_str::<Value>(&result.content) {
if value.get("type").and_then(Value::as_str) == Some("workflow_proposal") {
latest = Some(value);
}
}
}
}
}
latest
}
/// Lists persisted workflow suggestions. `status` filters to one lifecycle
/// state (the UI passes `New` for the active "Suggested for you" cards); `None`
/// returns every status.
+40
View File
@@ -1614,3 +1614,43 @@ async fn dismiss_unknown_suggestion_reports_not_found() {
let d = flows_dismiss_suggestion(&config, "missing").await.unwrap();
assert_eq!(d.value["dismissed"], json!(false));
}
// ─────────────────────────────────────────────────────────────────────────────
// FlowStreamTarget (Phase B copilot/scout streaming) — pure param plumbing.
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn flow_stream_target_none_without_thread_id() {
// No thread → headless run, regardless of request_id.
assert!(FlowStreamTarget::from_params(None, None).is_none());
assert!(FlowStreamTarget::from_params(None, Some("r-1".to_string())).is_none());
}
#[test]
fn flow_stream_target_blank_thread_id_is_absent() {
// Whitespace-only thread id is treated as no thread (callers pass raw input).
assert!(FlowStreamTarget::from_params(Some(" ".to_string()), None).is_none());
assert!(FlowStreamTarget::from_params(Some(String::new()), None).is_none());
}
#[test]
fn flow_stream_target_trims_and_keeps_request_id() {
let t = FlowStreamTarget::from_params(Some(" t-1 ".to_string()), Some(" r-1 ".to_string()))
.expect("stream target");
assert_eq!(t.thread_id, "t-1");
assert_eq!(t.request_id, "r-1");
}
#[test]
fn flow_stream_target_generates_request_id_when_absent_or_blank() {
// Absent request id → a fresh uuid is minted.
let a = FlowStreamTarget::from_params(Some("t-1".to_string()), None).expect("target");
assert!(!a.request_id.is_empty());
assert_ne!(a.request_id, a.thread_id);
// Blank request id is treated the same way.
let b = FlowStreamTarget::from_params(Some("t-1".to_string()), Some(" ".to_string()))
.expect("target");
assert!(!b.request_id.is_empty());
// Two mints are distinct uuids.
assert_ne!(a.request_id, b.request_id);
}
+206 -4
View File
@@ -131,6 +131,32 @@ fn flow_suggestion_fields() -> Vec<FieldSchema> {
]
}
/// Optional `thread_id` streaming param shared by `build` + `discover`. When
/// the copilot/scout passes a chat thread id, the turn streams live
/// text/thinking/tool/proposal socket events into that thread (Phase B) instead
/// of running headless; omitting it keeps the prior blocking-only behaviour.
fn stream_thread_id_input() -> FieldSchema {
FieldSchema {
name: "thread_id",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Chat thread to stream this turn into (copilot/scout live view). \
Omit for a headless run — the blocking result is returned either way.",
required: false,
}
}
/// Optional `request_id` streaming param (per-turn correlation id). Only
/// meaningful alongside `thread_id`; a fresh uuid is generated when absent.
fn stream_request_id_input() -> FieldSchema {
FieldSchema {
name: "request_id",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Per-turn correlation id for the streamed events (matches the \
frontend request_id). Generated when omitted; ignored without `thread_id`.",
required: false,
}
}
fn require_approval_input() -> FieldSchema {
FieldSchema {
name: "require_approval",
@@ -224,6 +250,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("list_runs"),
schemas("get_run"),
schemas("prune_runs"),
schemas("build"),
schemas("discover"),
schemas("list_suggestions"),
schemas("dismiss_suggestion"),
@@ -297,6 +324,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("prune_runs"),
handler: handle_prune_runs,
},
RegisteredController {
schema: schemas("build"),
handler: handle_build,
},
RegisteredController {
schema: schemas("discover"),
handler: handle_discover,
@@ -711,6 +742,77 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"build" => ControllerSchema {
namespace: "flows",
function: "build",
description: "Run the workflow_builder agent for one authoring turn. `mode` selects \
create (first draft from `instruction`), revise (refine the injected \
`graph`), repair (diagnose a failed `run_id` and fix), or build \
(instant-create: build + dry-run + save_workflow onto `flow_id`). The \
server renders the agent's brief — the frontend no longer crafts prompts. \
Returns `{ proposal, assistant_text, error }`, where `proposal` is the \
`{ type: 'workflow_proposal', name, graph, require_approval, summary, \
warnings }` the agent produced (or null). Only `build` may persist (via \
save_workflow onto an existing flow); it never enables or runs a flow.",
inputs: vec![
FieldSchema {
name: "mode",
ty: TypeSchema::String,
comment: "One of: `create` | `revise` | `repair` | `build`.",
required: true,
},
FieldSchema {
name: "instruction",
ty: TypeSchema::String,
comment: "The user's ask: description (create/build) or change instruction \
(revise); optional note for repair.",
required: false,
},
FieldSchema {
name: "graph",
ty: TypeSchema::Option(Box::new(TypeSchema::Json)),
comment: "The current draft WorkflowGraph, injected as context for \
revise/repair/build.",
required: false,
},
FieldSchema {
name: "flow_id",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Saved flow id — required for `build` (save target); optional \
elsewhere (lets the agent run_workflow it to test, with confirmation).",
required: false,
},
FieldSchema {
name: "run_id",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Failed run id (== thread id) for `repair`, so the agent can \
get_flow_run it.",
required: false,
},
FieldSchema {
name: "error",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Run-level error message for `repair`, if known.",
required: false,
},
FieldSchema {
name: "failing_node_ids",
ty: TypeSchema::Option(Box::new(TypeSchema::Json)),
comment: "Node ids implicated in the failure, for `repair` (array of strings).",
required: false,
},
stream_thread_id_input(),
stream_request_id_input(),
],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "`{ proposal, assistant_text, error }` — `proposal` is the workflow \
proposal the agent produced (or null); `error` is set if the run failed \
but a prior proposal was still captured.",
required: true,
}],
},
"discover" => ControllerSchema {
namespace: "flows",
function: "discover",
@@ -720,7 +822,7 @@ pub fn schemas(function: &str) -> ControllerSchema {
creates, enables, or runs a flow — turning a suggestion into a real flow \
is the user's separate 'Build this' action. Returns the active (new) \
suggestions after the run.",
inputs: vec![],
inputs: vec![stream_thread_id_input(), stream_request_id_input()],
outputs: vec![suggestions_output()],
},
"list_suggestions" => ControllerSchema {
@@ -966,13 +1068,50 @@ fn handle_prune_runs(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_discover(_params: Map<String, Value>) -> ControllerFuture {
fn handle_build(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(ops::flows_discover(&config).await?)
// Optional streaming target: when the copilot passes its chat `thread_id`
// the builder turn streams live text/tool/proposal events into that
// thread (Phase B). Read + strip the transport-only keys before the rest
// of the object is deserialized into the structured BuilderRequest.
let stream = read_flow_stream_target(&params);
// Deserialize the remaining param object into the structured BuilderRequest
// (mode/instruction/graph/flow_id/run_id/error/failing_node_ids). The
// stream keys are ignored (BuilderRequest doesn't declare them).
let req: crate::openhuman::flows::agents::workflow_builder::builder_prompt::BuilderRequest =
serde_json::from_value(Value::Object(params))
.map_err(|e| format!("invalid flows.build params: {e}"))?;
to_json(ops::flows_build(&config, req, stream).await?)
})
}
fn handle_discover(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
// Optional streaming target for the Flow Scout run (Phase B) — same
// `thread_id`/`request_id` convention as `flows.build`.
let stream = read_flow_stream_target(&params);
to_json(ops::flows_discover(&config, stream).await?)
})
}
/// Read the optional `thread_id` / `request_id` streaming params shared by
/// `flows.build` and `flows.discover` into an [`ops::FlowStreamTarget`].
/// Returns `None` (headless run) when no usable `thread_id` is present; a
/// missing `request_id` is filled with a fresh uuid inside `from_params`.
fn read_flow_stream_target(params: &Map<String, Value>) -> Option<ops::FlowStreamTarget> {
let thread_id = params
.get("thread_id")
.and_then(Value::as_str)
.map(str::to_string);
let request_id = params
.get("request_id")
.and_then(Value::as_str)
.map(str::to_string);
ops::FlowStreamTarget::from_params(thread_id, request_id)
}
fn handle_list_suggestions(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
@@ -1043,6 +1182,7 @@ mod tests {
"list_runs",
"get_run",
"prune_runs",
"build",
"discover",
"list_suggestions",
"dismiss_suggestion",
@@ -1054,7 +1194,7 @@ mod tests {
#[test]
fn all_registered_controllers_has_handler_per_schema() {
let controllers = all_registered_controllers();
assert_eq!(controllers.len(), 20);
assert_eq!(controllers.len(), 21);
let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect();
assert_eq!(
names,
@@ -1075,6 +1215,7 @@ mod tests {
"list_runs",
"get_run",
"prune_runs",
"build",
"discover",
"list_suggestions",
"dismiss_suggestion",
@@ -1228,6 +1369,67 @@ mod tests {
assert_eq!(required, vec!["run_id"]);
}
#[test]
fn schemas_build_exposes_optional_stream_params() {
let s = schemas("build");
assert_eq!(s.namespace, "flows");
// The only structurally required build input is `mode`.
let required: Vec<_> = s
.inputs
.iter()
.filter(|f| f.required)
.map(|f| f.name)
.collect();
assert_eq!(required, vec!["mode"]);
// The streaming params are present and optional.
let thread = s.inputs.iter().find(|f| f.name == "thread_id").unwrap();
assert!(!thread.required);
let request = s.inputs.iter().find(|f| f.name == "request_id").unwrap();
assert!(!request.required);
}
#[test]
fn schemas_discover_exposes_optional_stream_params() {
let s = schemas("discover");
assert_eq!(s.namespace, "flows");
// Discover has no required inputs — the two stream params are optional.
assert!(s.inputs.iter().all(|f| !f.required));
let names: Vec<_> = s.inputs.iter().map(|f| f.name).collect();
assert_eq!(names, vec!["thread_id", "request_id"]);
}
#[test]
fn read_flow_stream_target_none_without_thread_id() {
let mut params = Map::new();
// request_id alone is not enough — streaming needs a thread.
params.insert("request_id".to_string(), Value::String("r-1".to_string()));
assert!(read_flow_stream_target(&params).is_none());
// Blank thread id is also treated as absent.
params.insert("thread_id".to_string(), Value::String(" ".to_string()));
assert!(read_flow_stream_target(&params).is_none());
}
#[test]
fn read_flow_stream_target_uses_thread_and_request() {
let mut params = Map::new();
params.insert("thread_id".to_string(), Value::String("t-42".to_string()));
params.insert("request_id".to_string(), Value::String("r-9".to_string()));
let target = read_flow_stream_target(&params).expect("stream target");
assert_eq!(target.thread_id, "t-42");
assert_eq!(target.request_id, "r-9");
}
#[test]
fn read_flow_stream_target_generates_request_id_when_absent() {
let mut params = Map::new();
params.insert("thread_id".to_string(), Value::String("t-7".to_string()));
let target = read_flow_stream_target(&params).expect("stream target");
assert_eq!(target.thread_id, "t-7");
// A uuid was minted — non-empty and not the thread id.
assert!(!target.request_id.is_empty());
assert_ne!(target.request_id, target.thread_id);
}
#[test]
fn schemas_unknown_function_returns_placeholder() {
let s = schemas("does-not-exist");
@@ -176,6 +176,52 @@ pub fn resolve_model_for_hint(hint_or_tier: &str, config: &Config) -> String {
}
}
/// Map a managed tier name (or `hint:*` string) to the workload **role** whose
/// configured provider serves it.
///
/// This is the inverse of the role→tier routing `create_chat_provider` does:
/// callers that select a model *per unit of work by tier* (e.g. a tinyflows
/// `agent` node pinning `config.model = "reasoning-v1"`) use this to turn that
/// tier back into the role, then call [`create_chat_provider`] with it — so the
/// completion routes to that tier on the managed backend (or the role's BYOK
/// model) instead of some caller default. Unknown strings fall back to `"chat"`.
///
/// Kept deliberately small and standalone (no `Config`) — it is a pure lookup
/// over the tier constants, mirroring the `tier_to_role` table inside
/// [`resolve_model_for_hint`].
pub fn role_for_model_tier(hint_or_tier: &str) -> &'static str {
use crate::openhuman::config::{
MODEL_AGENTIC_V1, MODEL_BURST_V1, MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1,
MODEL_REASONING_V1, MODEL_SUMMARIZATION_V1, MODEL_VISION_V1,
};
// Normalise a `hint:*` alias to its concrete tier first.
let tier = match hint_or_tier.strip_prefix("hint:") {
Some("reasoning") => MODEL_REASONING_V1,
Some("chat") => MODEL_CHAT_V1,
Some("agentic") => MODEL_AGENTIC_V1,
Some("burst") => MODEL_BURST_V1,
Some("coding") => MODEL_CODING_V1,
Some("vision") => MODEL_VISION_V1,
Some("summarization") => MODEL_SUMMARIZATION_V1,
// Background subconscious rides the chat tier for its model.
Some("subconscious") => MODEL_CHAT_V1,
Some(_) => hint_or_tier,
None => hint_or_tier,
};
match tier {
MODEL_REASONING_V1 => "reasoning",
MODEL_CHAT_V1 | MODEL_REASONING_QUICK_V1 => "chat",
MODEL_AGENTIC_V1 => "agentic",
MODEL_BURST_V1 => "burst",
MODEL_CODING_V1 => "coding",
MODEL_VISION_V1 => "vision",
MODEL_SUMMARIZATION_V1 => "summarization",
_ => "chat",
}
}
/// Return whether `model` is a recognized OpenHuman backend tier name.
///
/// Used to guard against stale `default_model` values (e.g. set by older UI
@@ -2372,6 +2372,39 @@ fn resolve_model_for_hint_subconscious_reads_subconscious_provider() {
);
}
// ── role_for_model_tier ─────────────────────────────────────────────────
#[test]
fn role_for_model_tier_maps_tier_names_to_roles() {
// The demo flow pins these two tiers on its agent nodes; they must route to
// the reasoning and chat workloads respectively.
assert_eq!(role_for_model_tier("reasoning-v1"), "reasoning");
assert_eq!(role_for_model_tier("chat-v1"), "chat");
assert_eq!(role_for_model_tier("agentic-v1"), "agentic");
assert_eq!(role_for_model_tier("burst-v1"), "burst");
assert_eq!(role_for_model_tier("coding-v1"), "coding");
assert_eq!(role_for_model_tier("vision-v1"), "vision");
assert_eq!(role_for_model_tier("summarization-v1"), "summarization");
// The quick reasoning tier shares the chat workload for its model.
assert_eq!(role_for_model_tier("reasoning-quick-v1"), "chat");
}
#[test]
fn role_for_model_tier_normalises_hint_aliases() {
assert_eq!(role_for_model_tier("hint:reasoning"), "reasoning");
assert_eq!(role_for_model_tier("hint:chat"), "chat");
assert_eq!(role_for_model_tier("hint:coding"), "coding");
// Subconscious rides the chat tier's model.
assert_eq!(role_for_model_tier("hint:subconscious"), "chat");
}
#[test]
fn role_for_model_tier_unknown_falls_back_to_chat() {
assert_eq!(role_for_model_tier("gpt-4o"), "chat");
assert_eq!(role_for_model_tier("hint:unknown_tier"), "chat");
assert_eq!(role_for_model_tier(""), "chat");
}
#[test]
fn omlx_provider_builds_with_bearer_key() {
let mut config = crate::openhuman::config::Config::default();
+3 -1
View File
@@ -45,7 +45,9 @@ pub use error_code::{
is_backend_malformed_bad_request, is_managed_backend_envelope, managed_error_skips_sentry,
BackendErrorCode,
};
pub use factory::{create_chat_provider, provider_for_role, BYOK_INCOMPLETE_SENTINEL};
pub use factory::{
create_chat_provider, provider_for_role, role_for_model_tier, BYOK_INCOMPLETE_SENTINEL,
};
pub use ops::*;
pub use resolved_route::{
current_resolved_provider_route, current_route_slot, record_resolved_provider_route,
+2 -2
View File
@@ -251,13 +251,13 @@ const RESOURCE_CATALOG: &[PromptResource] = &[
uri: "openhuman://prompts/agents/flow_discovery",
name: "flow_discovery",
description: "Flow Scout — read-only workflow discovery agent that suggests automations from memory, threads, and integrations.",
content: include_str!("../agent_registry/agents/flow_discovery/prompt.md"),
content: include_str!("../flows/agents/flow_discovery/prompt.md"),
},
PromptResource {
uri: "openhuman://prompts/agents/workflow_builder",
name: "workflow_builder",
description: "Workflow authoring specialist that builds tinyflows automation graphs and returns proposals for review.",
content: include_str!("../agent_registry/agents/workflow_builder/prompt.md"),
content: include_str!("../flows/agents/workflow_builder/prompt.md"),
},
PromptResource {
uri: "openhuman://prompts/agents/agent_memory",
+504 -8
View File
@@ -17,8 +17,8 @@ use async_trait::async_trait;
use serde_json::{json, Value};
use tinyagents::graph::SqliteCheckpointer;
use tinyflows::caps::{
Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, StateStore, ToolInvoker,
WorkflowResolver,
AgentRunner, Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, StateStore,
ToolInvoker, WorkflowResolver,
};
use tinyflows::error::{EngineError, Result};
use tinyflows::model::WorkflowGraph;
@@ -31,7 +31,7 @@ use crate::openhuman::config::{Config, HttpRequestConfig};
use crate::openhuman::credentials::{HttpCredential, HttpCredentialsStore};
use crate::openhuman::flows;
use crate::openhuman::inference::provider::{
create_chat_provider, ChatMessage, ChatRequest, UsageInfo,
create_chat_provider, role_for_model_tier, ChatMessage, ChatRequest, UsageInfo,
};
use crate::openhuman::sandbox::{execute_in_sandbox, resolve_sandbox_policy};
use crate::openhuman::security::{
@@ -247,11 +247,14 @@ pub(crate) fn parse_llm_json(text: &str) -> Option<Value> {
/// **Structured output**: when the node requested it (an
/// `output_parser.schema` or `response_format: "json"` in the config), the
/// completion text is parsed as JSON and the **parsed object** is returned as
/// the response value — so a downstream node can bind `=item.<field>` (or
/// `=nodes.<agent_id>.item.<field>`) instead of receiving an opaque
/// `{text: "..."}` blob. A completion that doesn't parse falls back to the
/// legacy shape, where the agent node's `output_parser` sub-port can still
/// coerce it via the schema auto-fix path.
/// the response value; otherwise the `{text: "..."}` shape is returned. Either
/// way the tinyflows `agent` node wraps this in its stable output **envelope**
/// `{ json, text, raw }`, so a downstream node binds `=item.json.<field>` for
/// structured output or `=item.text` for prose (or
/// `=nodes.<agent_id>.item.json.<field>` across nodes) — the parsed-vs-`{text}`
/// shape is no longer visible to consumers. A completion that doesn't parse
/// still lets the agent node's `output_parser` sub-port coerce it via the
/// schema auto-fix path before enveloping.
pub struct OpenHumanLlm {
pub config: Arc<Config>,
}
@@ -269,6 +272,32 @@ impl LlmProvider for OpenHumanLlm {
.get("role")
.and_then(Value::as_str)
.unwrap_or("summarization");
// Per-node model selection: an `agent` node may pin a **managed tier**
// (`config.model = "reasoning-v1"` / `"chat-v1"`, or a `hint:*` alias).
// Map that tier back to the workload role whose provider serves it so
// the completion routes to that tier on the managed backend (or the
// role's BYOK model) instead of the node's default `role`. Unknown /
// absent model strings leave the role untouched. `config.model` is
// trusted node config, never model output.
let node_model = request
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty());
let role = match node_model {
Some(model) => {
let mapped = role_for_model_tier(model);
tracing::debug!(
target: "flows",
node_model = model,
mapped_role = mapped,
"[flows] llm.complete: node pinned a model tier — routing by mapped role"
);
mapped
}
None => role,
};
let temperature = request
.get("temperature")
.and_then(Value::as_f64)
@@ -376,6 +405,437 @@ impl LlmProvider for OpenHumanLlm {
}
}
/// [`AgentRunner`] backing an `agent` node's `agent_ref`. It runs the selected
/// agent kind by one of two paths, chosen by [`route_for_agent_ref`]:
///
/// 1. **Full harness turn** (the common case, Phase A). When `agent_ref` names a
/// harness [`AgentDefinition`](crate::openhuman::agent::harness::definition::AgentDefinition),
/// the node builds a real session agent
/// ([`Agent::from_config_for_agent`](crate::openhuman::agent::Agent::from_config_for_agent)
/// + `set_agent_definition_name`) and drives one full turn via
/// [`Agent::run_single`](crate::openhuman::agent::Agent::run_single) — the
/// complete tool loop. The definition's `ToolScope` / `sandbox_mode` /
/// `max_iterations` govern the turn, so an agent node gains its curated
/// toolset with no graph change. This is the same harness pattern
/// `flows_build` / `flows_discover` / cron / subconscious use, so "every node
/// is a tinyagents graph" still holds: `run_single` itself routes through the
/// default agent graph, i.e. a nested tinyagents graph (the agent turn) inside
/// the flow's tinyagents graph.
/// 2. **Persona-shaping completion fallback** (no regression for custom agents).
/// When `agent_ref` only resolves to a custom
/// [`AgentRegistryEntry`](crate::openhuman::agent_registry::AgentRegistryEntry)
/// (no harness definition), the node keeps the original single-completion
/// behavior: the entry's `system_prompt` / `model` are shaped on top of the
/// node request and run through [`OpenHumanLlm::complete`].
///
/// **Security.** No new origin is scoped here: the engine future already runs
/// under the flow's `Workflow` origin (`turn_origin`), so the user's autonomy
/// tier + approval gate apply to the inner turn automatically, and the agent
/// definition's `ToolScope`/sandbox is the inner gate. `agent_ref` is resolved
/// from trusted node config (never model output), so a prompt-injected
/// completion cannot pick an arbitrary agent kind.
///
/// **Per-item cost.** In per-item execution mode the engine calls
/// [`run_agent`](AgentRunner::run_agent) once per input item, so a full harness
/// turn (with memory injection) fans out one `Agent` per item. The batch size is
/// not visible inside a single `run_agent` call (the engine drives the fan-out),
/// so a "> 25 items" warning is not reachable here; it belongs to a future
/// host-side per-item guard. Memory injection per node turn is accepted for this
/// first cut (skip-memory is a follow-up).
pub struct OpenHumanAgentRunner {
pub config: Arc<Config>,
}
/// Which execution path an `agent_ref` routes to (see [`OpenHumanAgentRunner`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AgentRoute {
/// A harness `AgentDefinition` exists — run the full agent tool loop.
Harness,
/// No definition; fall back to the custom-registry persona completion.
RegistryFallback,
}
/// Decides the route for `agent_ref` by consulting the (already-initialised)
/// global `AgentDefinitionRegistry`: a harness definition wins; otherwise the
/// custom-registry fallback. Pure over the global registry so the selection is
/// unit-testable with `init_global_builtins`.
pub(crate) fn route_for_agent_ref(agent_ref: &str) -> AgentRoute {
let has_definition =
crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::global()
.map(|reg| reg.get(agent_ref).is_some())
.unwrap_or(false);
if has_definition {
AgentRoute::Harness
} else {
AgentRoute::RegistryFallback
}
}
/// The wall-clock timeout for one agent-node harness turn: the node's requested
/// `timeout_secs` clamped to `10..=600`, defaulting to `240` when unset. A hung
/// provider/tool call must never wedge the flow run.
pub(crate) fn clamp_run_timeout_secs(requested: Option<u64>) -> u64 {
requested.map(|s| s.clamp(10, 600)).unwrap_or(240)
}
/// Renders an agent-node completion `request` into the single user message
/// [`Agent::run_single`](crate::openhuman::agent::Agent::run_single) takes: the
/// `prompt` string when present and non-empty, else the `messages` array
/// flattened to `"<role>: <content>"` lines (blank entries skipped). Empty
/// string when neither yields content. Mirrors how [`OpenHumanLlm::complete`]
/// reads `prompt`/`messages`, collapsed to one string because the harness turn
/// entry point is single-message.
pub(crate) fn node_request_to_prompt(request: &Value) -> String {
if let Some(prompt) = request.get("prompt").and_then(Value::as_str) {
let prompt = prompt.trim();
if !prompt.is_empty() {
return prompt.to_string();
}
}
if let Some(entries) = request.get("messages").and_then(Value::as_array) {
let parts: Vec<String> = entries
.iter()
.filter_map(|entry| {
let content = entry.get("content").and_then(Value::as_str)?.trim();
if content.is_empty() {
return None;
}
let role = entry.get("role").and_then(Value::as_str).unwrap_or("user");
Some(format!("{role}: {content}"))
})
.collect();
if !parts.is_empty() {
return parts.join("\n\n");
}
}
String::new()
}
/// Model precedence for an agent node, returning the raw model string as
/// written:
/// 1. node `config.model` — a managed tier (`reasoning-v1`, `chat-v1`, …) or a
/// `hint:*` alias;
/// 2. the registry `entry_model` (custom agents);
/// 3. `None` — no override, so the harness definition's / role default stands.
///
/// Routing translation (tier → workload) happens at application time via
/// [`harness_model_default_override`]; this function is only the precedence pick,
/// so it stays config-free and trivially testable.
pub(crate) fn resolve_node_model(request: &Value, entry_model: Option<&str>) -> Option<String> {
if let Some(node_model) = request
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|m| !m.is_empty())
{
return Some(node_model.to_string());
}
entry_model
.map(str::trim)
.filter(|m| !m.is_empty())
.map(str::to_string)
}
/// Translates a managed tier / `hint:*` / model string into the `default_model`
/// value that routes a freshly-built harness [`Agent`](crate::openhuman::agent::Agent)
/// to the workload serving that tier. The session builder's `provider_role_for`
/// only routes the `hint:<role>` form to a specialised workload, so a bare tier
/// name (`reasoning-v1`) must be normalised to `hint:reasoning` here — otherwise
/// it would silently fall through to the chat workload. Mirrors the per-node
/// routing [`OpenHumanLlm::complete`] applies via
/// [`role_for_model_tier`](crate::openhuman::inference::provider::role_for_model_tier);
/// an unrecognised string maps to the chat workload, same as there.
pub(crate) fn harness_model_default_override(node_model: &str) -> String {
format!("hint:{}", role_for_model_tier(node_model))
}
/// Builds the JSON-steering instruction that a structured-output node needs (an
/// `output_parser.schema` or `response_format: "json"`), or `None` when the node
/// didn't request structured output. Shared shape with
/// [`OpenHumanLlm::complete`]'s inline steering; the harness path appends it to
/// the run prompt (rather than inserting a system message) because `run_single`
/// takes a single user message.
pub(crate) fn structured_output_instruction(request: &Value) -> Option<String> {
if !structured_output_requested(request) {
return None;
}
let mut instruction = "Respond with a single JSON object only — no prose, no \
markdown code fences."
.to_string();
if let Some(schema) = request
.get("output_parser")
.and_then(|p| p.get("schema"))
.filter(|s| !s.is_null())
{
instruction.push_str(&format!(
" The object must match this JSON Schema:\n{schema}"
));
}
Some(instruction)
}
/// Shapes an agent-node harness turn's final text into the node's output value,
/// mirroring [`OpenHumanLlm::complete`]: when the node requested structured
/// output and the text parses as JSON, the parsed object/array is returned so
/// downstream `=item.<field>` / `=nodes.<id>.item.<field>` bindings work;
/// otherwise `{ text, agent_ref }`. The vendor `agent` node then folds this into
/// the stable `{ json, text, raw }` envelope, and the `output_parser` sub-port
/// still applies.
pub(crate) fn build_agent_result(agent_ref: &str, final_text: &str, request: &Value) -> Value {
if structured_output_requested(request) {
if let Some(parsed) = parse_llm_json(final_text) {
tracing::debug!(
target: "flows",
agent_ref,
"[flows] agent_runner: structured output parsed from harness turn"
);
return parsed;
}
tracing::warn!(
target: "flows",
agent_ref,
"[flows] agent_runner: structured output requested but the harness turn did not parse \
as JSON falling back to the {{text}} shape (the output_parser sub-port may still \
coerce it)"
);
}
json!({ "text": final_text, "agent_ref": agent_ref })
}
#[async_trait]
impl AgentRunner for OpenHumanAgentRunner {
async fn run_agent(
&self,
agent_ref: &str,
request: Value,
conn: Option<&str>,
) -> Result<Value> {
// The harness definition registry must be initialised before we can
// build a named agent. Idempotent: a booted core already did this at
// startup; a bare flow run (tests, standalone) has not. A failure here
// is non-fatal — we log and fall through to the registry-entry route.
if let Err(e) =
crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::init_global(
&self.config.workspace_dir,
)
{
tracing::warn!(
target: "flows",
agent_ref,
error = %e,
"[flows] agent_runner: agent definition registry init failed — will attempt the \
custom registry-entry fallback"
);
}
match route_for_agent_ref(agent_ref) {
AgentRoute::Harness => {
tracing::info!(
target: "flows",
agent_ref,
"[flows] agent_runner: HARNESS path — running the full agent tool loop"
);
self.run_via_harness(agent_ref, request, conn).await
}
AgentRoute::RegistryFallback => {
tracing::info!(
target: "flows",
agent_ref,
"[flows] agent_runner: FALLBACK path — persona-shaping single completion for a \
custom registry entry"
);
self.run_via_registry_fallback(agent_ref, request, conn)
.await
}
}
}
}
impl OpenHumanAgentRunner {
/// Full harness turn: build a real session agent for `agent_ref` and drive
/// one `run_single` under the node's model override + timeout. See
/// [`OpenHumanAgentRunner`] for the security/origin contract.
async fn run_via_harness(
&self,
agent_ref: &str,
request: Value,
conn: Option<&str>,
) -> Result<Value> {
use crate::openhuman::agent::Agent;
if let Some(c) = conn {
tracing::debug!(
target: "flows",
conn = %c,
"[flows] agent_runner: connection_ref present but not resolved to a BYOK account \
for the harness turn (matches OpenHumanLlm)"
);
}
// Model precedence for a harness node: node `config.model` > the
// definition's own default. There is no custom registry `entry_model` on
// this path.
let node_model = resolve_node_model(&request, None);
// Apply the override the cron way (`run_agent_job`): a cloned `Config`
// with a new `default_model`, so we never mutate the shared config or
// invent a new Agent setter API. The tier is normalised to the
// `hint:<role>` form the session builder routes on.
let mut effective = (*self.config).clone();
if let Some(model) = node_model.as_deref() {
effective.default_model = Some(harness_model_default_override(model));
}
let mut agent = Agent::from_config_for_agent(&effective, agent_ref).map_err(|e| {
EngineError::Capability(format!(
"agent node: failed to build harness agent '{agent_ref}': {e:#}"
))
})?;
agent.set_agent_definition_name(agent_ref.to_string());
// The run message: the node prompt (or flattened messages), with the
// JSON-steering instruction appended when the node asked for structured
// output (run_single takes a single user message, so we can't inject a
// system message the way OpenHumanLlm::complete does).
let mut prompt = node_request_to_prompt(&request);
if let Some(instruction) = structured_output_instruction(&request) {
prompt = if prompt.is_empty() {
instruction
} else {
format!("{instruction}\n\n{prompt}")
};
}
let timeout_secs =
clamp_run_timeout_secs(request.get("timeout_secs").and_then(Value::as_u64));
tracing::debug!(
target: "flows",
agent_ref,
node_model = node_model.as_deref().unwrap_or("<definition-default>"),
default_model = effective.default_model.as_deref().unwrap_or("<config-default>"),
timeout_secs,
prompt_len = prompt.len(),
"[flows] agent_runner: dispatching full harness turn"
);
// No origin wrapper: the engine future already runs under the flow's
// Workflow origin, so the inner turn inherits the autonomy tier +
// approval gate; the definition's ToolScope/sandbox is the inner gate.
// Cancellation: the run_registry token aborts the engine future, and the
// inner turn drops with it.
let run = agent.run_single(&prompt);
let final_text =
match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), run).await {
Ok(Ok(text)) => text,
Ok(Err(e)) => {
tracing::warn!(
target: "flows",
agent_ref,
error = %e,
"[flows] agent_runner: harness turn failed"
);
return Err(EngineError::Capability(format!(
"agent node '{agent_ref}' turn failed: {e:#}"
)));
}
Err(_) => {
tracing::warn!(
target: "flows",
agent_ref,
timeout_secs,
"[flows] agent_runner: harness turn timed out"
);
return Err(EngineError::Capability(format!(
"agent node '{agent_ref}' timed out after {timeout_secs}s"
)));
}
};
Ok(build_agent_result(agent_ref, &final_text, &request))
}
/// Persona-shaping single-completion fallback for a custom
/// [`AgentRegistryEntry`](crate::openhuman::agent_registry::AgentRegistryEntry)
/// with no harness definition — the pre-Phase-A behavior, kept so custom
/// agents don't regress.
async fn run_via_registry_fallback(
&self,
agent_ref: &str,
request: Value,
conn: Option<&str>,
) -> Result<Value> {
// Resolve + validate the requested agent kind against the registry.
let entry = crate::openhuman::agent_registry::get_agent(agent_ref)
.await
.map_err(EngineError::Capability)?
.ok_or_else(|| {
EngineError::Capability(format!(
"agent node: unknown agent_ref '{agent_ref}' (neither a harness definition nor \
a custom agent registry entry)"
))
})?;
if !entry.enabled {
return Err(EngineError::Capability(format!(
"agent node: agent_ref '{agent_ref}' is disabled"
)));
}
tracing::debug!(
target: "flows",
agent_ref,
has_system_prompt = entry.system_prompt.is_some(),
model = entry.model.as_deref().unwrap_or("<role-default>"),
"[flows] agent_runner: applying custom registered agent-kind persona to the completion"
);
// Shape the completion by the agent kind: prepend the agent's system
// prompt (its persona) ahead of the node's messages, and adopt its model
// when the node didn't pin one. The completion itself runs through the
// same provider path as a plain agent turn (OpenHumanLlm::complete), so
// structured-output / envelope behavior is identical.
let mut request = request;
if let Some(system_prompt) = entry.system_prompt.as_deref().filter(|s| !s.is_empty()) {
prepend_system_message(&mut request, system_prompt);
}
if let Some(model) = entry.model.as_deref().filter(|s| !s.is_empty()) {
if request.get("model").and_then(Value::as_str).is_none() {
if let Value::Object(map) = &mut request {
map.insert("model".to_string(), Value::String(model.to_string()));
}
}
}
OpenHumanLlm {
config: self.config.clone(),
}
.complete(request, conn)
.await
}
}
/// Inserts `system_prompt` as the first `system` message of a completion
/// `request`, creating the `messages` array (seeded from any `prompt` string)
/// when the request doesn't already carry one. Mirrors how
/// [`OpenHumanLlm::complete`] reads `messages`/`prompt`.
fn prepend_system_message(request: &mut Value, system_prompt: &str) {
let Value::Object(map) = request else {
return;
};
let system_msg = json!({ "role": "system", "content": system_prompt });
match map.get_mut("messages").and_then(Value::as_array_mut) {
Some(messages) => messages.insert(0, system_msg),
None => {
// No `messages`: build one from the `prompt` string (if any).
let mut messages = vec![system_msg];
if let Some(prompt) = map.get("prompt").and_then(Value::as_str) {
messages.push(json!({ "role": "user", "content": prompt }));
}
map.insert("messages".to_string(), Value::Array(messages));
}
}
}
/// Parses a `"composio:<toolkit>:<connection_id>"` `connection_ref` (see the
/// node catalog, `my_docs/ohxtf/commons/12-node-catalog-0.2.md`) and returns
/// the trailing connection id segment. Values that don't match this shape
@@ -1530,6 +1990,9 @@ pub fn build_capabilities(config: Arc<Config>, state_namespace: impl Into<String
config: config.clone(),
namespace: state_namespace.into(),
}),
agent: Some(Arc::new(OpenHumanAgentRunner {
config: config.clone(),
})),
resolver: Arc::new(OpenHumanWorkflowResolver { config }),
}
}
@@ -1564,6 +2027,39 @@ mod tests {
use crate::openhuman::agent::prompts::types::IntegrationConnection;
use crate::openhuman::composio::ConnectedIntegration;
#[test]
fn prepend_system_message_builds_messages_from_prompt() {
// An agent-node request that carries only a `prompt` gets a `messages`
// array seeded with the agent-kind system prompt then the user prompt.
let mut req = json!({ "prompt": "fix the bug" });
prepend_system_message(&mut req, "You are a coding agent.");
let messages = req["messages"].as_array().expect("messages");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"], "You are a coding agent.");
assert_eq!(messages[1]["role"], "user");
assert_eq!(messages[1]["content"], "fix the bug");
}
#[test]
fn prepend_system_message_inserts_ahead_of_existing_messages() {
let mut req = json!({ "messages": [{ "role": "user", "content": "hi" }] });
prepend_system_message(&mut req, "persona");
let messages = req["messages"].as_array().expect("messages");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"], "persona");
assert_eq!(messages[1]["content"], "hi");
}
#[test]
fn prepend_system_message_ignores_non_object_request() {
// A non-object request is left untouched rather than panicking.
let mut req = json!("just a string");
prepend_system_message(&mut req, "persona");
assert_eq!(req, json!("just a string"));
}
fn integration(
toolkit: &str,
connected: bool,
+146
View File
@@ -488,3 +488,149 @@ async fn preflight_invoker_gates_the_mock_tool_path() {
.expect("native slug bypasses composio preflight");
assert_eq!(ok["tool"], "oh:web_search");
}
// ── OpenHumanAgentRunner: routing + request/model mapping (Phase A) ───────────
use super::caps::{
build_agent_result, clamp_run_timeout_secs, harness_model_default_override,
node_request_to_prompt, resolve_node_model, route_for_agent_ref, structured_output_instruction,
AgentRoute,
};
#[test]
fn node_request_to_prompt_prefers_prompt_string() {
let req = json!({ "prompt": " summarize this " });
assert_eq!(node_request_to_prompt(&req), "summarize this");
}
#[test]
fn node_request_to_prompt_flattens_messages_when_no_prompt() {
let req = json!({
"messages": [
{ "role": "system", "content": "be terse" },
{ "role": "user", "content": "hello" },
{ "role": "assistant", "content": "" }
]
});
// Blank content is skipped; each surviving entry is `role: content`.
assert_eq!(
node_request_to_prompt(&req),
"system: be terse\n\nuser: hello"
);
}
#[test]
fn node_request_to_prompt_empty_when_nothing_usable() {
assert_eq!(node_request_to_prompt(&json!({})), "");
assert_eq!(node_request_to_prompt(&json!({ "prompt": " " })), "");
assert_eq!(node_request_to_prompt(&json!({ "messages": [] })), "");
}
#[test]
fn resolve_node_model_precedence() {
// 1. Node config.model wins over the registry entry model (raw passthrough).
let req = json!({ "model": "reasoning-v1" });
assert_eq!(
resolve_node_model(&req, Some("chat-v1")).as_deref(),
Some("reasoning-v1")
);
// 2. No node model → the registry entry model is used.
let req = json!({ "prompt": "hi" });
assert_eq!(
resolve_node_model(&req, Some("custom-model")).as_deref(),
Some("custom-model")
);
// 3. Neither → None (the definition/role default stands).
assert_eq!(resolve_node_model(&req, None), None);
// Blank/whitespace strings are treated as absent.
let req = json!({ "model": " " });
assert_eq!(resolve_node_model(&req, Some(" ")), None);
}
#[test]
fn harness_model_default_override_normalises_tiers_to_hint_roles() {
// Bare managed tiers → the `hint:<role>` form the session builder routes on
// (a bare tier would otherwise fall through to the chat workload).
assert_eq!(
harness_model_default_override("reasoning-v1"),
"hint:reasoning"
);
assert_eq!(harness_model_default_override("chat-v1"), "hint:chat");
// `hint:*` aliases pass through their role.
assert_eq!(
harness_model_default_override("hint:reasoning"),
"hint:reasoning"
);
// Unrecognised strings map to the chat workload (matches OpenHumanLlm).
assert_eq!(harness_model_default_override("openai:gpt-4o"), "hint:chat");
}
#[test]
fn clamp_run_timeout_secs_bounds_and_default() {
assert_eq!(clamp_run_timeout_secs(None), 240);
assert_eq!(clamp_run_timeout_secs(Some(0)), 10); // below floor
assert_eq!(clamp_run_timeout_secs(Some(5)), 10);
assert_eq!(clamp_run_timeout_secs(Some(120)), 120);
assert_eq!(clamp_run_timeout_secs(Some(600)), 600);
assert_eq!(clamp_run_timeout_secs(Some(10_000)), 600); // above ceiling
}
#[test]
fn structured_output_instruction_only_when_requested() {
// Plain prose node — no steering.
assert!(structured_output_instruction(&json!({ "prompt": "hi" })).is_none());
// response_format: "json" triggers steering.
let inst = structured_output_instruction(&json!({ "response_format": "json" }))
.expect("json response_format requests structured output");
assert!(inst.contains("single JSON object"));
// An output_parser.schema is echoed into the instruction.
let inst = structured_output_instruction(&json!({
"output_parser": { "schema": { "type": "object", "required": ["plan"] } }
}))
.expect("output_parser.schema requests structured output");
assert!(inst.contains("JSON Schema"));
assert!(inst.contains("\"plan\""));
}
#[test]
fn build_agent_result_shapes_structured_vs_prose() {
// Prose node: `{ text, agent_ref }`.
let out = build_agent_result("researcher", "just prose", &json!({ "prompt": "x" }));
assert_eq!(out["text"], "just prose");
assert_eq!(out["agent_ref"], "researcher");
// Structured node whose text is JSON: the parsed object is returned (no
// agent_ref wrapper) so `=item.<field>` bindings work downstream.
let req = json!({ "response_format": "json" });
let out = build_agent_result("planner", "{\"plan\": \"do it\"}", &req);
assert_eq!(out["plan"], "do it");
assert!(out.get("agent_ref").is_none());
// Structured requested but unparseable text → `{text}` fallback shape.
let out = build_agent_result("planner", "not json", &req);
assert_eq!(out["text"], "not json");
assert_eq!(out["agent_ref"], "planner");
}
#[test]
fn route_for_agent_ref_selects_harness_for_definitions_else_fallback() {
// Ensure the global registry is populated (idempotent no-op if another test
// already initialised it; builtins are always present either way).
let _ =
crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::init_global_builtins(
);
// A shipped harness definition → full-loop harness path.
assert_eq!(route_for_agent_ref("workflow_builder"), AgentRoute::Harness);
assert_eq!(route_for_agent_ref("researcher"), AgentRoute::Harness);
// An id with no harness definition → the custom-registry completion fallback.
assert_eq!(
route_for_agent_ref("totally_unknown_custom_agent_xyz"),
AgentRoute::RegistryFallback
);
}
+10
View File
@@ -286,11 +286,21 @@ pub fn all_tools_with_runtime(
Box::new(GetFlowRunTool::new(config.clone())),
Box::new(ListFlowConnectionsTool::new(config.clone())),
Box::new(SearchToolCatalogTool::new()),
// Ground an `agent` node's `agent_ref` in real registered agent-kind ids
// (researcher / code_executor / …) — the agent analogue of
// search_tool_catalog. Read-only.
Box::new(ListAgentProfilesTool::new()),
Box::new(DryRunWorkflowTool::new(security.clone(), config.clone())),
// Real end-to-end test run of a SAVED flow (Write / external-effect). The
// workflow-builder prompt requires it to ask the user for confirmation
// first, and the flow's own approval gate still pauses outbound nodes.
Box::new(RunFlowTool::new(config.clone())),
// Persist a built graph onto an EXISTING saved flow (Write). The one
// deliberate carve-out from the belt's propose-only origin: the Flows
// prompt bar creates the flow first and hands the agent its id, so the
// copilot can finish the "build → dry-run → save" arc itself. It can
// never create a flow or change enabled/require_approval.
Box::new(SaveWorkflowTool::new(config.clone())),
// Flow Scout discovery: the `flow_discovery` agent's terminal emit
// sink. Read-only reasoning over the user's data ends by calling
// `suggest_workflows`, which persists workflow ideas for the Flows page
+507
View File
@@ -3,6 +3,7 @@
//! Isolates config under a temp `HOME` so auth profiles and the OpenHuman provider resolve
//! the same state directory. Run with: `cargo test --test json_rpc_e2e`
use std::collections::VecDeque;
use std::net::SocketAddr;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
@@ -122,6 +123,141 @@ fn with_chat_completion_requests<T>(f: impl FnOnce(&mut Vec<Value>) -> T) -> T {
}
}
// ── Scripted chat-completion FIFO (in-process, node-mock-free) ───────────────
//
// The heuristic `chat_completions` mock can't script tool calls, so agent-driven
// flows (`flows_build` / `flows_discover`) and agent-node flow runs had no way to
// exercise a deterministic tool_call / planner-drafter arc in-process. This FIFO
// mirrors the node mock's `llmForcedResponses` (`scripts/mock-api/routes/llm.mjs`)
// without pulling in a node dependency: a test pushes OpenAI-shape response
// bodies, and the chat-completions handler pops the first *matching* one before
// falling back to its heuristic.
//
// Entries are optionally gated on a marker substring appearing in the serialized
// request body (`when_contains`). That gate is what makes the arc robust against
// the "FIFO-drain" hazard the plan flags: a builder/scout turn may emit side
// completions (memory summarization, etc.) that don't carry the marker, so they
// hit the heuristic fallback instead of consuming a scripted response meant for a
// different node. Matching entries are still served in strict FIFO order among
// themselves, so a two-turn tool loop (tool_call turn, then terminal-text turn)
// stays ordered.
static FORCED_CHAT_COMPLETIONS: OnceLock<Mutex<VecDeque<ForcedChatCompletion>>> = OnceLock::new();
#[derive(Clone)]
struct ForcedChatCompletion {
/// When `Some`, only serve this entry if the serialized request body contains
/// this marker. `None` matches any request (strict FIFO).
when_contains: Option<String>,
/// The OpenAI-shape chat-completion body to return verbatim.
body: Value,
}
fn with_forced_chat_completions<T>(f: impl FnOnce(&mut VecDeque<ForcedChatCompletion>) -> T) -> T {
let mutex = FORCED_CHAT_COMPLETIONS.get_or_init(|| Mutex::new(VecDeque::new()));
match mutex.lock() {
Ok(mut guard) => f(&mut guard),
Err(poisoned) => {
let mut guard = poisoned.into_inner();
f(&mut guard)
}
}
}
/// Drop every queued scripted completion. Call between test sections so a leftover
/// entry can never bleed into an unrelated arc.
#[allow(dead_code)]
fn clear_forced_chat_completions() {
with_forced_chat_completions(|q| q.clear());
}
/// RAII guard that drains the process-global scripted-completion FIFO on drop —
/// including on an assertion panic mid-test. A trailing `clear_forced_chat_completions()`
/// call only runs on the happy path; if an `assert!` unwinds first, the leftover
/// scripted entries would otherwise be consumed by a later test sharing this
/// binary. Hold one for the whole body of any test that queues completions.
#[allow(dead_code)]
struct ScriptedFifoGuard;
impl Drop for ScriptedFifoGuard {
fn drop(&mut self) {
clear_forced_chat_completions();
}
}
/// Queue an unconditional scripted completion (strict FIFO — matches any request).
#[allow(dead_code)]
fn push_forced_chat_completion(body: Value) {
with_forced_chat_completions(|q| {
q.push_back(ForcedChatCompletion {
when_contains: None,
body,
})
});
}
/// Queue a scripted completion gated on `marker` appearing in the request body.
#[allow(dead_code)]
fn push_forced_chat_completion_when(marker: &str, body: Value) {
with_forced_chat_completions(|q| {
q.push_back(ForcedChatCompletion {
when_contains: Some(marker.to_string()),
body,
})
});
}
/// Pop the first queued completion whose marker matches `request_body`
/// (unconditional entries always match), preserving FIFO order among matches.
fn take_forced_chat_completion(request_body: &Value) -> Option<Value> {
let haystack = request_body.to_string();
with_forced_chat_completions(|q| {
let idx = q.iter().position(|entry| match &entry.when_contains {
Some(marker) => haystack.contains(marker.as_str()),
None => true,
})?;
q.remove(idx).map(|entry| entry.body)
})
}
/// A scripted plain-text assistant completion in the OpenAI shape the managed
/// backend parses (mirrors the node mock's non-streaming `makeChoice`).
#[allow(dead_code)]
fn forced_text_completion(content: &str) -> Value {
json!({
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": content },
"finish_reason": "stop"
}],
"usage": { "prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20 }
})
}
/// A scripted single-tool-call assistant completion. `arguments` is serialized to
/// the JSON string the OpenAI tool-calling contract (and the harness) expects.
#[allow(dead_code)]
fn forced_tool_call_completion(tool_name: &str, arguments: Value) -> Value {
json!({
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": format!("call_{tool_name}"),
"type": "function",
"function": {
"name": tool_name,
"arguments": arguments.to_string()
}
}]
},
"finish_reason": "tool_calls"
}],
"usage": { "prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20 }
})
}
fn mock_upstream_router() -> Router {
const GENERAL_TOKEN: &str = "e2e-test-jwt";
const BILLING_TOKEN: &str = "e2e-billing-jwt";
@@ -242,6 +378,11 @@ fn mock_upstream_router() -> Router {
"body": body.clone(),
}))
});
// A scripted response (tool_call or plain completion) wins over the
// heuristic when one is queued and matches this request.
if let Some(forced) = take_forced_chat_completion(&body) {
return Json(forced);
}
let is_triage_turn = body
.get("messages")
.and_then(Value::as_array)
@@ -12605,6 +12746,372 @@ async fn json_rpc_flows_suggestion_lifecycle_methods_are_wired() {
rpc_join.abort();
}
/// Minimal config for the agent-backed flows arc: like `write_min_config` but
/// pins `default_model = "chat-v1"` so an agent node on the **chat** tier
/// resolves to `chat-v1` on the managed backend while a **reasoning**-tier node
/// resolves to `reasoning-v1` — letting the full-arc test assert the two nodes
/// routed to distinct managed tiers.
fn write_flows_tier_config(openhuman_dir: &Path, api_origin: &str) {
let cfg = format!(
r#"api_url = "{api_origin}"
default_model = "chat-v1"
default_temperature = 0.7
chat_onboarding_completed = true
[secrets]
encrypt = false
# Keep tool-result content raw: the workflow_builder agent runs the reasoning
# tier with Full TokenJuice compaction, which CCR-compresses a large
# `propose_workflow` result into a `tinyjuice_retrieve` reference. `flows_build`
# extracts the proposal from the agent's tool history by JSON-parsing that
# content, so compaction must stay off for the arc to be observable in-test.
[context]
compaction_enabled = false
"#
);
fn write_config_file(config_dir: &Path, cfg: &str) {
std::fs::create_dir_all(config_dir).expect("mkdir openhuman");
std::fs::write(config_dir.join("config.toml"), cfg).expect("write config");
}
write_config_file(openhuman_dir, &cfg);
if openhuman_dir
.file_name()
.is_some_and(|name| name == std::ffi::OsStr::new(".openhuman"))
{
write_config_file(&openhuman_dir.join("users").join("local"), &cfg);
}
let _: openhuman_core::openhuman::config::Config =
toml::from_str(&cfg).expect("config toml must match Config schema");
}
/// The canonical "Research brief (Opus plans, Sonnet drafts)" demo graph — the
/// same shape as `app/src/lib/flows/templates/opus-sonnet-brief.json`, inlined so
/// the e2e test doesn't depend on reading the frontend template at runtime. A
/// `trigger` feeds a reasoning-tier `planner` (structured `{plan, angle}`) into a
/// chat-tier `drafter` that references `nodes.planner.item.json.plan`, then a
/// `transform` shapes `{topic, plan, draft}`.
fn opus_sonnet_demo_graph() -> Value {
json!({
"schema_version": 1,
"name": "Research brief (Opus plans, Sonnet drafts)",
"nodes": [
{
"id": "trigger",
"kind": "trigger",
"name": "Run manually with a topic",
"config": { "trigger_kind": "manual" }
},
{
"id": "planner",
"kind": "agent",
"name": "Plan the brief (reasoning tier)",
"config": {
"model": "reasoning-v1",
"prompt": "=\"You are a research lead. Draft a concise research plan (3-5 steps) and pick one distinctive angle for a brief on: \" + (.run.trigger.topic // \"the requested topic\")",
"output_parser": {
"schema": {
"type": "object",
"required": ["plan", "angle"],
"properties": {
"plan": { "type": "string" },
"angle": { "type": "string" }
}
}
}
}
},
{
"id": "drafter",
"kind": "agent",
"name": "Draft the brief (chat tier)",
"config": {
"model": "chat-v1",
"prompt": "=\"Using the plan and angle below, write a polished research brief (~300 words).\\n\\nPlan:\\n\" + (.nodes.planner.item.json.plan // \"\") + \"\\n\\nAngle:\\n\" + (.nodes.planner.item.json.angle // \"\")"
}
},
{
"id": "shape",
"kind": "transform",
"name": "Shape the result",
"config": {
"set": {
"topic": "=run.trigger.topic",
"plan": "=nodes.planner.item.json.plan",
"draft": "=nodes.drafter.item.text"
}
}
}
],
"edges": [
{ "from_node": "trigger", "from_port": "main", "to_node": "planner", "to_port": "main" },
{ "from_node": "planner", "from_port": "main", "to_node": "drafter", "to_port": "main" },
{ "from_node": "drafter", "from_port": "main", "to_node": "shape", "to_port": "main" }
]
})
}
/// Full flows arc over JSON-RPC, driven entirely by the scripted-completion FIFO:
///
/// 1. `flows_discover` — the Flow Scout scripts a `suggest_workflows` tool call;
/// the recorded suggestion is asserted via `flows_list_suggestions`.
/// 2. `flows_build` — the workflow_builder scripts a `propose_workflow` tool call
/// carrying the Opus+Sonnet demo graph; the returned proposal is asserted.
/// 3. `flows_create` + `flows_run` — the saved demo graph runs with two scripted
/// plain completions (planner structured JSON, then drafter text). We assert
/// the run completed, the planner's structured plan flowed into the drafter's
/// prompt (data passing), and the two agent nodes routed to the expected
/// managed tiers (`reasoning-v1` / `chat-v1`).
///
/// Runs on the agent-sized worker stack because the builder/scout turns and the
/// agent-node run drive the full harness (deep async stacks).
#[test]
fn json_rpc_flows_full_arc_discover_build_create_run() {
run_json_rpc_e2e_on_agent_stack(
"json_rpc_flows_full_arc_discover_build_create_run",
json_rpc_flows_full_arc_discover_build_create_run_inner,
);
}
async fn json_rpc_flows_full_arc_discover_build_create_run_inner() {
let _env_lock = json_rpc_e2e_env_lock();
// Drain the scripted-completion FIFO even if an assertion below panics, so a
// leftover entry can't bleed into another test sharing this binary.
let _fifo_guard = ScriptedFifoGuard;
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let _api_url_guard = EnvVarGuard::unset("OPENHUMAN_API_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{mock_addr}");
write_flows_tier_config(&openhuman_home, &mock_origin);
let user_scoped_dir = openhuman_home.join("users").join("e2e-user");
write_flows_tier_config(&user_scoped_dir, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{rpc_addr}");
tokio::time::sleep(Duration::from_millis(100)).await;
// The managed backend needs an active session JWT to build agent-node /
// builder / scout completions; the mock validates it via GET /auth/me.
let store = post_json_rpc(
&rpc_base,
71_000,
"openhuman.auth_store_session",
json!({ "token": "e2e-test-jwt", "user_id": "e2e-user" }),
)
.await;
assert_no_jsonrpc_error(&store, "store_session");
// ── 1. flows_discover: the scout scripts a suggest_workflows tool call ──
clear_forced_chat_completions();
with_chat_completion_requests(|requests| requests.clear());
// Turn 1: emit the terminal `suggest_workflows` tool call. Turn 2: a plain
// reply ends the scout's tool loop. Both gate on "suggest_workflows" (present
// in the scout's tool schema every turn, absent from every other agent).
push_forced_chat_completion_when(
"suggest_workflows",
forced_tool_call_completion(
"suggest_workflows",
json!({
"suggestions": [{
"title": "Weekly research brief",
"one_liner": "Plan then draft a research brief on a topic each week.",
"rationale": "Your recent threads keep asking for structured research briefs.",
"build_prompt": "Build a workflow where a reasoning model plans a research brief and a chat model drafts it."
}]
}),
),
);
push_forced_chat_completion_when(
"suggest_workflows",
forced_text_completion("Recorded one workflow suggestion for you."),
);
let discover = post_json_rpc(&rpc_base, 71_001, "openhuman.flows_discover", json!({})).await;
let discovered = peel_logs_envelope(assert_no_jsonrpc_error(&discover, "flows_discover"))
.as_array()
.cloned()
.expect("flows_discover returns a suggestions array");
assert!(
discovered
.iter()
.any(|s| s.get("title").and_then(Value::as_str) == Some("Weekly research brief")),
"scripted suggestion should be returned by flows_discover: {discovered:?}"
);
// The suggestion must have persisted — assert via the independent list path.
let listed = post_json_rpc(
&rpc_base,
71_002,
"openhuman.flows_list_suggestions",
json!({ "status": "new" }),
)
.await;
let listed = peel_logs_envelope(assert_no_jsonrpc_error(&listed, "flows_list_suggestions"))
.as_array()
.cloned()
.expect("flows_list_suggestions returns an array");
assert!(
listed
.iter()
.any(|s| s.get("title").and_then(Value::as_str) == Some("Weekly research brief")),
"scripted suggestion should be persisted: {listed:?}"
);
// ── 2. flows_build: the builder scripts a propose_workflow tool call ──
clear_forced_chat_completions();
with_chat_completion_requests(|requests| requests.clear());
let demo_graph = opus_sonnet_demo_graph();
// Turn 1: propose the demo graph. Turn 2: a plain reply ends the builder's
// loop. Both gate on "propose_workflow" (in the builder's tool schema every
// turn, absent from the scout / plain agent nodes).
push_forced_chat_completion_when(
"propose_workflow",
forced_tool_call_completion(
"propose_workflow",
json!({
"name": "Research brief (Opus plans, Sonnet drafts)",
"graph": demo_graph.clone()
}),
),
);
push_forced_chat_completion_when(
"propose_workflow",
forced_text_completion("Here's a research-brief workflow: Opus plans, Sonnet drafts."),
);
let build = post_json_rpc(
&rpc_base,
71_010,
"openhuman.flows_build",
json!({
"mode": "create",
"instruction": "Build a research brief workflow where a reasoning model plans and a chat model drafts."
}),
)
.await;
let build_out = peel_logs_envelope(assert_no_jsonrpc_error(&build, "flows_build"));
let proposal = build_out
.get("proposal")
.filter(|p| !p.is_null())
.expect("flows_build returns a non-null proposal");
assert_eq!(
proposal.get("type").and_then(Value::as_str),
Some("workflow_proposal")
);
let proposed_ids: Vec<&str> = proposal
.pointer("/graph/nodes")
.and_then(Value::as_array)
.expect("proposal graph has nodes")
.iter()
.filter_map(|n| n.get("id").and_then(Value::as_str))
.collect();
assert!(
proposed_ids.contains(&"planner") && proposed_ids.contains(&"drafter"),
"proposal graph must carry the agent nodes: {proposed_ids:?}"
);
// ── 3. flows_create + flows_run: the saved graph runs its agent nodes ──
clear_forced_chat_completions();
with_chat_completion_requests(|requests| requests.clear());
with_chat_completion_models(|models| models.clear());
let create = post_json_rpc(
&rpc_base,
71_020,
"openhuman.flows_create",
json!({ "name": "Research brief (e2e)", "graph": demo_graph.clone() }),
)
.await;
let flow_id = peel_logs_envelope(assert_no_jsonrpc_error(&create, "flows_create"))
.get("id")
.and_then(Value::as_str)
.expect("flow id from flows_create")
.to_string();
// Planner (reasoning tier, structured): return the `{plan, angle}` object.
// The plan carries a marker so the drafter request can be proven to reference
// it. Gated on "research lead" — unique to the planner's rendered prompt.
push_forced_chat_completion_when(
"research lead",
forced_text_completion(
"{\"plan\":\"PLAN_MARKER: interview 3 experts, survey the literature, synthesize findings.\",\"angle\":\"a contrarian systems view\"}",
),
);
// Drafter (chat tier, plain text). Gated on "polished research brief" — unique
// to the drafter's rendered prompt.
push_forced_chat_completion_when(
"polished research brief",
forced_text_completion("DRAFT_MARKER: a polished research brief distilled from the plan."),
);
let run = post_json_rpc(
&rpc_base,
71_021,
"openhuman.flows_run",
json!({ "id": flow_id, "input": { "topic": "renewable microgrids" } }),
)
.await;
let run_out = peel_logs_envelope(assert_no_jsonrpc_error(&run, "flows_run"));
assert!(
run_out
.get("pending_approvals")
.and_then(Value::as_array)
.is_some_and(|a| a.is_empty()),
"run should complete with no pending approvals: {run_out}"
);
let output_str = run_out
.get("output")
.expect("run output present")
.to_string();
assert!(
output_str.contains("PLAN_MARKER") && output_str.contains("DRAFT_MARKER"),
"run output should carry the planner's plan and the drafter's text: {output_str}"
);
// Assert the two agent-node completions routed to the expected managed tiers
// and that the planner's structured plan flowed into the drafter's prompt.
let requests = with_chat_completion_requests(|requests| requests.clone());
let body_of = |r: &Value| r.get("body").map(Value::to_string).unwrap_or_default();
let planner_req = requests
.iter()
.find(|r| body_of(r).contains("research lead"))
.cloned()
.expect("planner completion should have been captured");
assert_eq!(
planner_req.get("model").and_then(Value::as_str),
Some("reasoning-v1"),
"planner node (reasoning tier) must resolve to reasoning-v1"
);
let drafter_req = requests
.iter()
.find(|r| body_of(r).contains("polished research brief"))
.cloned()
.expect("drafter completion should have been captured");
assert_eq!(
drafter_req.get("model").and_then(Value::as_str),
Some("chat-v1"),
"drafter node (chat tier) must resolve to chat-v1"
);
assert!(
body_of(&drafter_req).contains("PLAN_MARKER"),
"drafter prompt must reference the planner's structured plan (graph data passing)"
);
// Hygiene: never let a leftover scripted completion bleed into another test
// sharing this process (the FIFO is a process-global static).
clear_forced_chat_completions();
mock_join.abort();
rpc_join.abort();
}
/// Approval park + DENY over JSON-RPC (issue G4): a gate with both a `main`
/// edge (→ `downstream`) and an `error` edge (→ `recover`). Resuming with the
/// gate in `rejections` routes the denied gate's error item to `recover`, and
+401
View File
@@ -0,0 +1,401 @@
//! Live end-to-end demo of the flows agents + Opus/Sonnet demo workflow against
//! a real backend.
//!
//! Like `live_routing_e2e.rs`, this is intentionally `#[ignore]` because it
//! requires:
//! - a reachable backend URL
//! - a valid user session JWT
//! - real network I/O, real model spend, and real side effects
//!
//! It drives the whole flows arc through the shared harness:
//! flows_discover → the Flow Scout records suggestions
//! flows_build → the workflow_builder proposes a graph from a short brief
//! flows_create → save the canonical Opus-plans / Sonnet-drafts demo graph
//! flows_run → run it on a live topic and print each step's output
//!
//! Run manually (or via `scripts/live-flows-demo.sh`):
//! OPENHUMAN_LIVE_API_URL="https://<your-backend>" \
//! OPENHUMAN_LIVE_TOKEN="<jwt>" \
//! OPENHUMAN_LIVE_USER_ID="<user-id>" \
//! cargo test --test live_flows_demo_e2e -- --ignored --nocapture
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use serde_json::{json, Value};
use tempfile::tempdir;
use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR};
use openhuman_core::core::jsonrpc::build_core_http_router;
static LIVE_E2E_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
static LIVE_RPC_AUTH_INIT: OnceLock<()> = OnceLock::new();
const TEST_RPC_TOKEN: &str = "live-flows-demo-e2e-local-token";
struct EnvVarGuard {
key: &'static str,
old: Option<String>,
}
impl EnvVarGuard {
fn set_to_path(key: &'static str, path: &Path) -> Self {
let old = std::env::var(key).ok();
// SAFETY: EnvVarGuard is only used after acquiring live_e2e_env_lock(),
// which serializes process-global env mutations.
unsafe { std::env::set_var(key, path.as_os_str()) };
Self { key, old }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.old {
// SAFETY: See set_to_path; teardown runs under the same lock.
Some(v) => unsafe { std::env::set_var(self.key, v) },
None => unsafe { std::env::remove_var(self.key) },
}
}
}
fn live_e2e_env_lock() -> std::sync::MutexGuard<'static, ()> {
let mutex = LIVE_E2E_ENV_LOCK.get_or_init(|| Mutex::new(()));
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn required_env(name: &str) -> String {
std::env::var(name).unwrap_or_else(|_| panic!("missing required env var: {name}"))
}
/// Seed a config that routes agent-node/chat workloads to the live managed
/// backend. `default_model = "chat-v1"` so the chat-tier `drafter` node resolves
/// to `chat-v1` while the reasoning-tier `planner` node pins `reasoning-v1`.
fn write_live_config(openhuman_dir: &Path, api_origin: &str) {
let cfg = format!(
r#"api_url = "{api_origin}"
default_model = "chat-v1"
default_temperature = 0.7
chat_onboarding_completed = true
[secrets]
encrypt = false
"#
);
fn write_config_file(config_dir: &Path, cfg: &str) {
std::fs::create_dir_all(config_dir).expect("mkdir openhuman");
std::fs::write(config_dir.join("config.toml"), cfg).expect("write config");
}
write_config_file(openhuman_dir, &cfg);
if openhuman_dir
.file_name()
.is_some_and(|name| name == std::ffi::OsStr::new(".openhuman"))
{
write_config_file(&openhuman_dir.join("users").join("local"), &cfg);
}
}
async fn post_json_rpc(rpc_base: &str, id: i64, method: &str, params: Value) -> Value {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(360))
.build()
.expect("client");
let resp = client
.post(format!("{rpc_base}/rpc"))
.header("Authorization", format!("Bearer {TEST_RPC_TOKEN}"))
.json(&json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params
}))
.send()
.await
.unwrap_or_else(|e| panic!("POST {method}: {e}"));
resp.json::<Value>().await.expect("rpc json body")
}
fn assert_no_jsonrpc_error<'a>(v: &'a Value, context: &str) -> &'a Value {
if let Some(err) = v.get("error") {
panic!("{context}: JSON-RPC error: {err}");
}
v.get("result")
.unwrap_or_else(|| panic!("{context}: missing result: {v}"))
}
/// Peel the `{ "result": inner, "logs": [...] }` envelope that flows ops add.
fn peel_logs_envelope(v: &Value) -> &Value {
if v.get("logs").is_some() {
v.get("result").unwrap_or(v)
} else {
v
}
}
async fn serve_rpc() -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
ensure_test_rpc_auth();
let app = build_core_http_router(false);
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.expect("bind ephemeral listener");
let addr = listener.local_addr().expect("listener addr");
let join = tokio::spawn(async move {
axum::serve(listener, app.into_make_service())
.await
.expect("rpc server should run");
});
(addr, join)
}
fn ensure_test_rpc_auth() {
LIVE_RPC_AUTH_INIT.get_or_init(|| {
// SAFETY: set_var runs exactly once across all test threads via the
// OnceLock guard, before any concurrent env reads.
unsafe { std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN) };
let token_dir = std::env::temp_dir().join("openhuman-live-flows-demo-e2e-auth");
init_rpc_token(&token_dir).expect("init rpc auth token for live_flows_demo_e2e");
});
}
/// The canonical "Research brief (Opus plans, Sonnet drafts)" demo graph — the
/// same shape as `app/src/lib/flows/templates/opus-sonnet-brief.json`.
fn opus_sonnet_demo_graph() -> Value {
json!({
"schema_version": 1,
"name": "Research brief (Opus plans, Sonnet drafts)",
"nodes": [
{
"id": "trigger",
"kind": "trigger",
"name": "Run manually with a topic",
"config": { "trigger_kind": "manual" }
},
{
"id": "planner",
"kind": "agent",
"name": "Plan the brief (reasoning tier)",
"config": {
"model": "reasoning-v1",
"prompt": "=\"You are a research lead. Draft a concise research plan (3-5 steps) and pick one distinctive angle for a brief on: \" + (.run.trigger.topic // \"the requested topic\")",
"output_parser": {
"schema": {
"type": "object",
"required": ["plan", "angle"],
"properties": {
"plan": { "type": "string" },
"angle": { "type": "string" }
}
}
}
}
},
{
"id": "drafter",
"kind": "agent",
"name": "Draft the brief (chat tier)",
"config": {
"model": "chat-v1",
"prompt": "=\"Using the plan and angle below, write a polished research brief (~300 words).\\n\\nPlan:\\n\" + (.nodes.planner.item.json.plan // \"\") + \"\\n\\nAngle:\\n\" + (.nodes.planner.item.json.angle // \"\")"
}
},
{
"id": "shape",
"kind": "transform",
"name": "Shape the result",
"config": {
"set": {
"topic": "=run.trigger.topic",
"plan": "=nodes.planner.item.json.plan",
"draft": "=nodes.drafter.item.text"
}
}
}
],
"edges": [
{ "from_node": "trigger", "from_port": "main", "to_node": "planner", "to_port": "main" },
{ "from_node": "planner", "from_port": "main", "to_node": "drafter", "to_port": "main" },
{ "from_node": "drafter", "from_port": "main", "to_node": "shape", "to_port": "main" }
]
})
}
#[tokio::test]
#[ignore = "requires live backend URL + valid token"]
async fn live_flows_demo_discover_build_save_run() {
let _env_lock = live_e2e_env_lock();
let api_url = required_env("OPENHUMAN_LIVE_API_URL");
let token = required_env("OPENHUMAN_LIVE_TOKEN");
let user_id = required_env("OPENHUMAN_LIVE_USER_ID");
let topic = std::env::var("OPENHUMAN_LIVE_FLOWS_TOPIC")
.unwrap_or_else(|_| "the state of grid-scale battery storage in 2026".to_string());
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
write_live_config(&openhuman_home, &api_url);
write_live_config(&openhuman_home.join("users").join(&user_id), &api_url);
let (rpc_addr, rpc_join) = serve_rpc().await;
let rpc_base = format!("http://{rpc_addr}");
tokio::time::sleep(Duration::from_millis(100)).await;
let store = post_json_rpc(
&rpc_base,
1,
"openhuman.auth_store_session",
json!({ "token": token, "user_id": user_id }),
)
.await;
assert_no_jsonrpc_error(&store, "store_session");
println!("\n=== live flows demo: session stored for {user_id} ===");
// 1. flows_discover — the Flow Scout records suggestions.
println!("\n--- flows_discover (Flow Scout) ---");
let discover = post_json_rpc(&rpc_base, 2, "openhuman.flows_discover", json!({})).await;
let suggestions = peel_logs_envelope(assert_no_jsonrpc_error(&discover, "flows_discover"))
.as_array()
.cloned()
.unwrap_or_default();
println!("scout returned {} suggestion(s):", suggestions.len());
for s in &suggestions {
println!(
" • {} — {}",
s.get("title")
.and_then(Value::as_str)
.unwrap_or("<untitled>"),
s.get("one_liner").and_then(Value::as_str).unwrap_or("")
);
}
// 2. flows_build — the workflow_builder proposes a graph from a short brief.
println!("\n--- flows_build (workflow_builder) ---");
let build = post_json_rpc(
&rpc_base,
3,
"openhuman.flows_build",
json!({
"mode": "create",
"instruction": "Build a research-brief workflow: a reasoning model plans the brief \
(steps + a distinctive angle), then a chat model drafts it."
}),
)
.await;
let build_out = peel_logs_envelope(assert_no_jsonrpc_error(&build, "flows_build"));
match build_out.get("proposal").filter(|p| !p.is_null()) {
Some(proposal) => {
let node_kinds: Vec<&str> = proposal
.pointer("/graph/nodes")
.and_then(Value::as_array)
.map(|nodes| {
nodes
.iter()
.filter_map(|n| n.get("kind").and_then(Value::as_str))
.collect()
})
.unwrap_or_default();
println!(
"builder proposed '{}' with nodes {:?}",
proposal
.get("name")
.and_then(Value::as_str)
.unwrap_or("<unnamed>"),
node_kinds
);
}
None => println!(
"builder returned no proposal (assistant_text: {})",
build_out
.get("assistant_text")
.and_then(Value::as_str)
.unwrap_or("")
),
}
// 3. flows_create — save the canonical Opus/Sonnet demo graph.
println!("\n--- flows_create (Opus+Sonnet demo) ---");
let create = post_json_rpc(
&rpc_base,
4,
"openhuman.flows_create",
json!({
"name": "Research brief (Opus plans, Sonnet drafts) — live demo",
"graph": opus_sonnet_demo_graph()
}),
)
.await;
let flow = peel_logs_envelope(assert_no_jsonrpc_error(&create, "flows_create"));
let flow_id = flow
.get("id")
.and_then(Value::as_str)
.expect("flow id from flows_create")
.to_string();
println!("saved flow {flow_id}");
// 4. flows_run — run it on a live topic and print each step's output.
println!("\n--- flows_run (topic: {topic}) ---");
let run = post_json_rpc(
&rpc_base,
5,
"openhuman.flows_run",
json!({ "id": flow_id, "input": { "topic": topic } }),
)
.await;
let run_out = peel_logs_envelope(assert_no_jsonrpc_error(&run, "flows_run"));
let thread_id = run_out
.get("thread_id")
.and_then(Value::as_str)
.unwrap_or("<none>");
let pending = run_out
.get("pending_approvals")
.and_then(Value::as_array)
.map(|a| a.len())
.unwrap_or(0);
println!("run thread_id={thread_id} pending_approvals={pending}");
// Per-node output from the terminal run state (`output.nodes.<id>`), plus the
// managed tier each agent node pinned via `config.model`.
if let Some(nodes) = run_out.pointer("/output/nodes").and_then(Value::as_object) {
for (node_id, state) in nodes {
let item = state
.get("items")
.and_then(Value::as_array)
.and_then(|items| items.first())
.cloned()
.unwrap_or(Value::Null);
println!("\n[node {node_id}]");
println!(
" {}",
serde_json::to_string_pretty(&item).unwrap_or_default()
);
}
}
println!("\nmodels used: planner→reasoning-v1, drafter→chat-v1 (per node config.model)");
// Pull the persisted run row for the recorded per-step models/status.
let run_row = post_json_rpc(
&rpc_base,
6,
"openhuman.flows_get_run",
json!({ "run_id": thread_id }),
)
.await;
let run_row = peel_logs_envelope(assert_no_jsonrpc_error(&run_row, "flows_get_run"));
println!(
"\nrun status: {}",
run_row
.get("status")
.and_then(Value::as_str)
.unwrap_or("<unknown>")
);
println!("\n=== live flows demo complete ===\n");
rpc_join.abort();
}