mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(flows): prompt bar → instant canvas with backend-driven copilot builder (#4578)
This commit is contained in:
Generated
+1
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -3937,6 +3937,11 @@ const messages: TranslationMap = {
|
||||
'flows.nodeConfig.agent.modelHint':
|
||||
'Choisissez un niveau de capacité — l’espace 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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 跳转。请重试。',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+43
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+15
-9
@@ -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;
|
||||
+34
-12
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(¶ms);
|
||||
// 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(¶ms);
|
||||
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(¶ms).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(¶ms).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(¶ms).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(¶ms).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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Vendored
+1
-1
Submodule vendor/tinyflows updated: 52209b50f1...8b01c8877f
Reference in New Issue
Block a user