sync: editable workflows from private repo

- Users can edit/create workflows from Settings → Workflows tab
- File-based storage for custom workflows
- Bundled workflows serve as fallback when no user file exists
- Added createEdge tool access for workflows (Quick Link)
- Cleaned up stale terminology in workflow prompts

Files synced:
- src/services/workflows/workflowFileService.ts (NEW)
- app/api/workflows/[key]/route.ts (NEW)
- src/services/workflows/registry.ts
- src/components/settings/WorkflowsViewer.tsx
- src/tools/infrastructure/registry.ts
- src/services/agents/wiseRAHExecutor.ts
- src/config/workflows/integrate.ts
- src/config/prompts/wise-rah.ts
This commit is contained in:
“BeeRad”
2026-01-04 16:30:25 +11:00
parent 8190df8cf2
commit a3647c0693
8 changed files with 804 additions and 100 deletions
+132
View File
@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from 'next/server';
import { saveWorkflow, deleteWorkflow, userWorkflowExists } from '@/services/workflows/workflowFileService';
import { WorkflowRegistry, BUNDLED_WORKFLOW_KEYS } from '@/services/workflows/registry';
// PUT /api/workflows/[key] - Create or update a workflow
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ key: string }> }
) {
try {
const { key } = await params;
const body = await request.json();
// Validate required fields
if (!body.displayName || typeof body.displayName !== 'string') {
return NextResponse.json(
{ success: false, error: 'displayName is required' },
{ status: 400 }
);
}
if (!body.instructions || typeof body.instructions !== 'string') {
return NextResponse.json(
{ success: false, error: 'instructions is required' },
{ status: 400 }
);
}
// Save the workflow
saveWorkflow({
key,
displayName: body.displayName.trim(),
description: body.description?.trim() || '',
instructions: body.instructions,
enabled: body.enabled !== false,
requiresFocusedNode: body.requiresFocusedNode !== false,
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error saving workflow:', error);
return NextResponse.json(
{ success: false, error: 'Failed to save workflow' },
{ status: 500 }
);
}
}
// DELETE /api/workflows/[key] - Delete a workflow (resets to default if bundled)
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ key: string }> }
) {
try {
const { key } = await params;
// Check if user file exists
if (!userWorkflowExists(key)) {
// If it's a bundled workflow, nothing to delete
if (BUNDLED_WORKFLOW_KEYS.has(key)) {
return NextResponse.json(
{ success: false, error: 'Cannot delete bundled workflow (no user override exists)' },
{ status: 400 }
);
}
return NextResponse.json(
{ success: false, error: 'Workflow not found' },
{ status: 404 }
);
}
const deleted = deleteWorkflow(key);
if (!deleted) {
return NextResponse.json(
{ success: false, error: 'Failed to delete workflow' },
{ status: 500 }
);
}
// If this was a bundled workflow, it will now fall back to default
const isBundled = BUNDLED_WORKFLOW_KEYS.has(key);
return NextResponse.json({
success: true,
resetToDefault: isBundled,
});
} catch (error) {
console.error('Error deleting workflow:', error);
return NextResponse.json(
{ success: false, error: 'Failed to delete workflow' },
{ status: 500 }
);
}
}
// GET /api/workflows/[key] - Get a single workflow
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ key: string }> }
) {
try {
const { key } = await params;
const workflow = await WorkflowRegistry.getWorkflowByKey(key);
if (!workflow) {
return NextResponse.json(
{ success: false, error: 'Workflow not found' },
{ status: 404 }
);
}
// Include metadata about whether it's user-modified
const hasUserOverride = userWorkflowExists(key);
const isBundled = BUNDLED_WORKFLOW_KEYS.has(key);
return NextResponse.json({
success: true,
data: {
...workflow,
isBundled,
hasUserOverride,
},
});
} catch (error) {
console.error('Error fetching workflow:', error);
return NextResponse.json(
{ success: false, error: 'Failed to fetch workflow' },
{ status: 500 }
);
}
}
+388 -59
View File
@@ -12,89 +12,319 @@ interface WorkflowDefinition {
requiresFocusedNode: boolean; requiresFocusedNode: boolean;
primaryActor: 'oracle' | 'main'; primaryActor: 'oracle' | 'main';
expectedOutcome?: string; expectedOutcome?: string;
isBundled?: boolean;
hasUserOverride?: boolean;
}
interface EditingWorkflow {
key: string;
displayName: string;
description: string;
instructions: string;
enabled: boolean;
requiresFocusedNode: boolean;
isNew: boolean;
} }
export default function WorkflowsViewer() { export default function WorkflowsViewer() {
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]); const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<EditingWorkflow | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { const loadWorkflows = async () => {
const load = async () => {
try { try {
const res = await fetch('/api/workflows'); const res = await fetch('/api/workflows');
const result = await res.json(); const result = await res.json();
if (result.success) setWorkflows(result.data); if (result.success) {
// Fetch additional metadata for each workflow
const enriched = await Promise.all(
result.data.map(async (w: WorkflowDefinition) => {
try {
const detailRes = await fetch(`/api/workflows/${w.key}`);
const detail = await detailRes.json();
return detail.success ? detail.data : w;
} catch {
return w;
}
})
);
setWorkflows(enriched);
}
} catch (e) { } catch (e) {
console.error('Failed to load workflows:', e); console.error('Failed to load workflows:', e);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
load();
useEffect(() => {
loadWorkflows();
}, []); }, []);
const handleEdit = (workflow: WorkflowDefinition) => {
setEditing({
key: workflow.key,
displayName: workflow.displayName,
description: workflow.description,
instructions: workflow.instructions,
enabled: workflow.enabled,
requiresFocusedNode: workflow.requiresFocusedNode,
isNew: false,
});
setError(null);
};
const handleNewWorkflow = () => {
setEditing({
key: '',
displayName: '',
description: '',
instructions: '',
enabled: true,
requiresFocusedNode: true,
isNew: true,
});
setError(null);
};
const handleCancel = () => {
setEditing(null);
setError(null);
};
const generateKey = (name: string): string => {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
};
const handleSave = async () => {
if (!editing) return;
const key = editing.isNew ? generateKey(editing.displayName) : editing.key;
if (!key) {
setError('Please enter a workflow name');
return;
}
if (!editing.instructions.trim()) {
setError('Please enter instructions');
return;
}
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/workflows/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: editing.displayName,
description: editing.description,
instructions: editing.instructions,
enabled: editing.enabled,
requiresFocusedNode: editing.requiresFocusedNode,
}),
});
const result = await res.json();
if (result.success) {
setEditing(null);
await loadWorkflows();
} else {
setError(result.error || 'Failed to save workflow');
}
} catch (e) {
setError('Failed to save workflow');
console.error('Save error:', e);
} finally {
setSaving(false);
}
};
const handleDelete = async (workflow: WorkflowDefinition) => {
const action = workflow.isBundled ? 'reset to default' : 'delete';
if (!confirm(`Are you sure you want to ${action} "${workflow.displayName}"?`)) {
return;
}
try {
const res = await fetch(`/api/workflows/${workflow.key}`, {
method: 'DELETE',
});
const result = await res.json();
if (result.success) {
await loadWorkflows();
} else {
alert(result.error || 'Failed to delete workflow');
}
} catch (e) {
alert('Failed to delete workflow');
console.error('Delete error:', e);
}
};
if (loading) { if (loading) {
return <div style={loadingStyle}>Loading...</div>; return <div style={loadingStyle}>Loading...</div>;
} }
// Editing view
if (editing) {
return ( return (
<div style={containerStyle}> <div style={containerStyle}>
<p style={descStyle}>Available workflows. Click to view instructions.</p> <div style={headerStyle}>
<span style={{ fontSize: 14, fontWeight: 500 }}>
{editing.isNew ? 'New Workflow' : `Edit: ${editing.displayName}`}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={handleCancel} style={buttonStyle} disabled={saving}>
Cancel
</button>
<button onClick={handleSave} style={primaryButtonStyle} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{error && <div style={errorStyle}>{error}</div>}
<div style={formStyle}>
<div style={fieldStyle}>
<label style={labelStyle}>Name</label>
<input
type="text"
value={editing.displayName}
onChange={(e) => setEditing({ ...editing, displayName: e.target.value })}
placeholder="My Workflow"
style={inputStyle}
disabled={saving}
/>
</div>
<div style={fieldStyle}>
<label style={labelStyle}>Description (shown to agent)</label>
<input
type="text"
value={editing.description}
onChange={(e) => setEditing({ ...editing, description: e.target.value })}
placeholder="Brief description of what this workflow does"
style={inputStyle}
disabled={saving}
/>
<span style={hintStyle}>Keep this short it tells the agent when to use this workflow</span>
</div>
<div style={fieldStyle}>
<label style={labelStyle}>Instructions</label>
<textarea
value={editing.instructions}
onChange={(e) => setEditing({ ...editing, instructions: e.target.value })}
placeholder="Enter the workflow instructions..."
style={textareaStyle}
disabled={saving}
/>
</div>
<div style={checkboxRowStyle}>
<label style={checkboxLabelStyle}>
<input
type="checkbox"
checked={editing.enabled}
onChange={(e) => setEditing({ ...editing, enabled: e.target.checked })}
disabled={saving}
/>
Enabled
</label>
<label style={checkboxLabelStyle}>
<input
type="checkbox"
checked={editing.requiresFocusedNode}
onChange={(e) => setEditing({ ...editing, requiresFocusedNode: e.target.checked })}
disabled={saving}
/>
Requires focused node
</label>
</div>
</div>
</div>
);
}
// List view
return (
<div style={containerStyle}>
<div style={headerStyle}>
<p style={descStyle}>Workflows available to the agent.</p>
<button onClick={handleNewWorkflow} style={primaryButtonStyle}>
+ New Workflow
</button>
</div>
{workflows.length === 0 ? ( {workflows.length === 0 ? (
<div style={emptyStyle}>No workflows defined.</div> <div style={emptyStyle}>No workflows defined.</div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{workflows.map((w) => { {workflows.map((w) => (
const expanded = expandedId === w.id; <div key={w.key} style={cardStyle}>
return ( <div style={cardContentStyle}>
<div key={w.id} style={cardStyle}> <div style={{ flex: 1 }}>
<div <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
onClick={() => setExpandedId(expanded ? null : w.id)}
style={cardHeaderStyle}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={titleStyle}>{w.displayName}</span> <span style={titleStyle}>{w.displayName}</span>
<span style={keyStyle}>{w.key}</span> <span style={keyStyle}>{w.key}</span>
</div> {w.hasUserOverride && w.isBundled && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <span style={modifiedBadgeStyle}>modified</span>
<span style={{ )}
fontSize: 11, {!w.enabled && (
color: w.enabled ? '#22c55e' : '#6b7280', <span style={disabledBadgeStyle}>disabled</span>
}}>
{w.enabled ? 'Enabled' : 'Disabled'}
</span>
<span style={{
fontSize: 12,
color: '#6b7280',
transform: expanded ? 'rotate(180deg)' : 'rotate(0)',
transition: 'transform 0.15s',
}}>
</span>
</div>
</div>
<div style={descRowStyle}>{w.description}</div>
{expanded && (
<div style={expandedStyle}>
<div style={instructionsStyle}>{w.instructions}</div>
</div>
)} )}
</div> </div>
); <div style={descRowStyle}>{w.description}</div>
})} </div>
<div style={actionsStyle}>
<button onClick={() => handleEdit(w)} style={buttonStyle}>
Edit
</button>
{/* Show delete for user-created, or reset for modified bundled */}
{(!w.isBundled || w.hasUserOverride) && (
<button
onClick={() => handleDelete(w)}
style={w.isBundled ? resetButtonStyle : deleteButtonStyle}
>
{w.isBundled ? 'Reset' : 'Delete'}
</button>
)}
</div>
</div>
</div>
))}
</div> </div>
)} )}
</div> </div>
); );
} }
// Styles
const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' }; const containerStyle: CSSProperties = { padding: 24, height: '100%', overflow: 'auto' };
const loadingStyle: CSSProperties = { padding: 24, color: '#6b7280' }; const loadingStyle: CSSProperties = { padding: 24, color: '#6b7280' };
const descStyle: CSSProperties = { fontSize: 13, color: '#6b7280', marginBottom: 20 }; const descStyle: CSSProperties = { fontSize: 13, color: '#6b7280', margin: 0 };
const emptyStyle: CSSProperties = { fontSize: 13, color: '#6b7280', textAlign: 'center', padding: 32 }; const emptyStyle: CSSProperties = { fontSize: 13, color: '#6b7280', textAlign: 'center', padding: 32 };
const headerStyle: CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
};
const cardStyle: CSSProperties = { const cardStyle: CSSProperties = {
background: 'rgba(255, 255, 255, 0.02)', background: 'rgba(255, 255, 255, 0.02)',
border: '1px solid rgba(255, 255, 255, 0.06)', border: '1px solid rgba(255, 255, 255, 0.06)',
@@ -102,34 +332,133 @@ const cardStyle: CSSProperties = {
overflow: 'hidden', overflow: 'hidden',
}; };
const cardHeaderStyle: CSSProperties = { const cardContentStyle: CSSProperties = {
padding: '14px 16px', padding: '14px 16px',
cursor: 'pointer',
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'flex-start',
gap: 16,
}; };
const titleStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: '#e5e7eb' }; const titleStyle: CSSProperties = { fontSize: 13, fontWeight: 500, color: '#e5e7eb' };
const keyStyle: CSSProperties = { fontSize: 11, fontFamily: 'monospace', color: '#6b7280' }; const keyStyle: CSSProperties = { fontSize: 11, fontFamily: 'monospace', color: '#6b7280' };
const descRowStyle: CSSProperties = { fontSize: 12, color: '#9ca3af', padding: '0 16px 14px', lineHeight: 1.5 }; const descRowStyle: CSSProperties = { fontSize: 12, color: '#9ca3af', lineHeight: 1.5 };
const expandedStyle: CSSProperties = { const actionsStyle: CSSProperties = {
padding: 16, display: 'flex',
borderTop: '1px solid rgba(255, 255, 255, 0.04)', gap: 8,
background: 'rgba(0, 0, 0, 0.2)', flexShrink: 0,
}; };
const instructionsStyle: CSSProperties = { const buttonStyle: CSSProperties = {
padding: '6px 12px',
fontSize: 12, fontSize: 12,
fontFamily: 'monospace', background: 'rgba(255, 255, 255, 0.05)',
color: '#d1d5db', border: '1px solid rgba(255, 255, 255, 0.1)',
whiteSpace: 'pre-wrap',
lineHeight: 1.6,
padding: 12,
background: 'rgba(255, 255, 255, 0.02)',
border: '1px solid rgba(255, 255, 255, 0.06)',
borderRadius: 6, borderRadius: 6,
maxHeight: 300, color: '#e5e7eb',
overflow: 'auto', cursor: 'pointer',
};
const primaryButtonStyle: CSSProperties = {
...buttonStyle,
background: 'rgba(34, 197, 94, 0.2)',
borderColor: 'rgba(34, 197, 94, 0.3)',
color: '#22c55e',
};
const deleteButtonStyle: CSSProperties = {
...buttonStyle,
background: 'rgba(239, 68, 68, 0.1)',
borderColor: 'rgba(239, 68, 68, 0.2)',
color: '#ef4444',
};
const resetButtonStyle: CSSProperties = {
...buttonStyle,
background: 'rgba(251, 191, 36, 0.1)',
borderColor: 'rgba(251, 191, 36, 0.2)',
color: '#fbbf24',
};
const modifiedBadgeStyle: CSSProperties = {
fontSize: 10,
padding: '2px 6px',
background: 'rgba(251, 191, 36, 0.15)',
color: '#fbbf24',
borderRadius: 4,
};
const disabledBadgeStyle: CSSProperties = {
fontSize: 10,
padding: '2px 6px',
background: 'rgba(107, 114, 128, 0.2)',
color: '#6b7280',
borderRadius: 4,
};
const formStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 16,
};
const fieldStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
};
const labelStyle: CSSProperties = {
fontSize: 12,
fontWeight: 500,
color: '#9ca3af',
};
const hintStyle: CSSProperties = {
fontSize: 11,
color: '#6b7280',
};
const inputStyle: CSSProperties = {
padding: '10px 12px',
fontSize: 13,
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: 6,
color: '#e5e7eb',
outline: 'none',
};
const textareaStyle: CSSProperties = {
...inputStyle,
minHeight: 300,
fontFamily: 'monospace',
fontSize: 12,
lineHeight: 1.6,
resize: 'vertical',
};
const checkboxRowStyle: CSSProperties = {
display: 'flex',
gap: 24,
};
const checkboxLabelStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
color: '#e5e7eb',
cursor: 'pointer',
};
const errorStyle: CSSProperties = {
padding: '10px 12px',
marginBottom: 16,
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.2)',
borderRadius: 6,
color: '#ef4444',
fontSize: 13,
}; };
+4 -4
View File
@@ -11,7 +11,7 @@ Available tools:
- queryNodes — search nodes by title/content/dimensions across ENTIRE database - queryNodes — search nodes by title/content/dimensions across ENTIRE database
- getNodesById — retrieve full node data - getNodesById — retrieve full node data
- queryEdge — inspect existing edges - queryEdge — inspect existing edges
- searchContentEmbeddings — semantic search across ALL nodes (not just pinned) - searchContentEmbeddings — semantic search across ALL nodes
- webSearch — external research when necessary - webSearch — external research when necessary
- think — internal planning/reflection (use once per workflow unless plan changes) - think — internal planning/reflection (use once per workflow unless plan changes)
- updateNode — append content to nodes (tool handles appending automatically) - updateNode — append content to nodes (tool handles appending automatically)
@@ -24,16 +24,16 @@ When you receive a workflow task:
2. Call think once to produce a numbered plan matching the workflow steps. 2. Call think once to produce a numbered plan matching the workflow steps.
3. Execute the plan step-by-step: 3. Execute the plan step-by-step:
- Extract key entities (names, projects, concepts) from the node - Extract key entities (names, projects, concepts) from the node
- Search the FULL database using those entities (ignore pinned context during search) - Search the FULL database using those entities
- Find both obvious (structural) and thematic connections - Find both obvious (structural) and thematic connections
- Then contextualize findings with pinned context - Contextualize findings using background context (top nodes by edge count)
4. Stay within the tool budget and avoid redundant queries. 4. Stay within the tool budget and avoid redundant queries.
5. When calling updateNode, provide ONLY the new content (never include existing content) - tool appends automatically. 5. When calling updateNode, provide ONLY the new content (never include existing content) - tool appends automatically.
6. Finish with a concise Task / Actions / Result / Nodes / Follow-up summary. 6. Finish with a concise Task / Actions / Result / Nodes / Follow-up summary.
</execution> </execution>
<constraints> <constraints>
- Search the ENTIRE database, not just pinned nodes - Search the ENTIRE database, not just top nodes
- Extract entities first, then search using those entities - Extract entities first, then search using those entities
- Use minimal tool calls needed for high-quality output - Use minimal tool calls needed for high-quality output
- Keep responses structured, factual, and ≤120 words - Keep responses structured, factual, and ≤120 words
+6 -6
View File
@@ -18,7 +18,7 @@ WORKFLOW STEPS
- Summarize the core insight in one sentence - Summarize the core insight in one sentence
2. SEARCH THE DATABASE FOR CONNECTIONS 2. SEARCH THE DATABASE FOR CONNECTIONS
DO NOT reference pinned context yet. Search the ENTIRE database: Search the ENTIRE database:
a) Obvious structural connections: a) Obvious structural connections:
- If names mentioned → queryNodes to find existing nodes about those people - If names mentioned → queryNodes to find existing nodes about those people
@@ -33,10 +33,10 @@ WORKFLOW STEPS
- Aim for 38 strong connections, not 20 weak ones - Aim for 38 strong connections, not 20 weak ones
- Check existing edges with queryEdge to avoid duplicating connections - Check existing edges with queryEdge to avoid duplicating connections
3. CONTEXTUALIZE WITH PINNED NODES 3. CONTEXTUALIZE WITH TOP NODES
NOW review the supplied PINNED CONTEXT: Review the BACKGROUND CONTEXT (top nodes by edge count):
- Why might this node matter given the user's focus areas? - Why might this node matter given the user's focus areas?
- Does it advance any themes visible in pinned nodes? - Does it advance any themes visible in their most connected nodes?
- Keep this brief 12 sentences maximum - Keep this brief 12 sentences maximum
4. APPEND INTEGRATION ANALYSIS 4. APPEND INTEGRATION ANALYSIS
@@ -52,7 +52,7 @@ WORKFLOW STEPS
- [NODE:456:"Title"] — [why: ...] - [NODE:456:"Title"] — [why: ...]
- [continue for 38 connections found in step 2] - [continue for 38 connections found in step 2]
**Relevance:** [12 sentences connecting to user's pinned context themes] **Relevance:** [12 sentences connecting to user's top nodes/focus areas]
CRITICAL: Send ONLY this new section. The tool will automatically append it to existing content. CRITICAL: Send ONLY this new section. The tool will automatically append it to existing content.
@@ -62,7 +62,7 @@ WORKFLOW STEPS
Reply with: Task / Actions / Result / Nodes / Follow-up (≤120 words) Reply with: Task / Actions / Result / Nodes / Follow-up (≤120 words)
CRITICAL RULES CRITICAL RULES
- Search the FULL database, not just pinned nodes - Search the FULL database, not just top nodes
- Use entities from step 1 to guide searches in step 2 - Use entities from step 1 to guide searches in step 2
- Call updateNode EXACTLY ONCE - after success, move to step 5 immediately - Call updateNode EXACTLY ONCE - after success, move to step 5 immediately
- Keep total tool calls ≤ 18 (be efficient) - Keep total tool calls ≤ 18 (be efficient)
+5 -2
View File
@@ -163,9 +163,9 @@ export class WiseRAHExecutor {
); );
// Enforce read-only constraint - remove write tools EXCEPT for workflows // Enforce read-only constraint - remove write tools EXCEPT for workflows
// Workflows (like integrate) need updateNode for direct content updates // Workflows need updateNode for content updates and createEdge for Quick Link workflow
const writeToolsToRemove = isWorkflow const writeToolsToRemove = isWorkflow
? ['createNode', 'createEdge', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH'] ? ['createNode', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH']
: ['createNode', 'updateNode', 'createEdge', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH']; : ['createNode', 'updateNode', 'createEdge', 'updateEdge', 'embedContent', 'youtubeExtract', 'websiteExtract', 'paperExtract', 'delegateToWiseRAH'];
writeToolsToRemove.forEach(toolName => { writeToolsToRemove.forEach(toolName => {
@@ -178,6 +178,9 @@ export class WiseRAHExecutor {
if (isWorkflow && 'updateNode' in wrappedTools) { if (isWorkflow && 'updateNode' in wrappedTools) {
console.log('✅ [WiseRAHExecutor] updateNode preserved for workflow execution'); console.log('✅ [WiseRAHExecutor] updateNode preserved for workflow execution');
} }
if (isWorkflow && 'createEdge' in wrappedTools) {
console.log('✅ [WiseRAHExecutor] createEdge preserved for workflow execution');
}
console.log('🔒 [WiseRAHExecutor] Final tools after read-only enforcement:', Object.keys(wrappedTools)); console.log('🔒 [WiseRAHExecutor] Final tools after read-only enforcement:', Object.keys(wrappedTools));
console.log('📝 [WiseRAHExecutor] Starting manual agentic loop...'); console.log('📝 [WiseRAHExecutor] Starting manual agentic loop...');
+55 -6
View File
@@ -1,8 +1,9 @@
import { INTEGRATE_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/integrate'; import { INTEGRATE_WORKFLOW_INSTRUCTIONS } from '@/config/workflows/integrate';
import type { WorkflowDefinition } from './types'; import type { WorkflowDefinition } from './types';
import { listUserWorkflows, loadUserWorkflow } from './workflowFileService';
export class WorkflowRegistry { // Bundled default workflows (always available as fallback)
private static readonly WORKFLOWS: Record<string, WorkflowDefinition> = { const BUNDLED_WORKFLOWS: Record<string, WorkflowDefinition> = {
'integrate': { 'integrate': {
id: 1, id: 1,
key: 'integrate', key: 'integrate',
@@ -14,17 +15,65 @@ export class WorkflowRegistry {
primaryActor: 'oracle', primaryActor: 'oracle',
expectedOutcome: 'Focused node updated with insights; 3-5 high-value edges created', expectedOutcome: 'Focused node updated with insights; 3-5 high-value edges created',
} }
}; };
// Set of bundled workflow keys (for UI to know which can be "reset to default")
export const BUNDLED_WORKFLOW_KEYS = new Set(Object.keys(BUNDLED_WORKFLOWS));
function userWorkflowToDefinition(uw: ReturnType<typeof loadUserWorkflow>, id: number): WorkflowDefinition {
if (!uw) throw new Error('Cannot convert null workflow');
return {
id,
key: uw.key,
displayName: uw.displayName,
description: uw.description,
instructions: uw.instructions,
enabled: uw.enabled,
requiresFocusedNode: uw.requiresFocusedNode,
primaryActor: 'oracle',
expectedOutcome: undefined,
};
}
export class WorkflowRegistry {
static async getWorkflowByKey(key: string): Promise<WorkflowDefinition | null> { static async getWorkflowByKey(key: string): Promise<WorkflowDefinition | null> {
return this.WORKFLOWS[key] || null; // Try user file first
const userWorkflow = loadUserWorkflow(key);
if (userWorkflow) {
return userWorkflowToDefinition(userWorkflow, BUNDLED_WORKFLOWS[key]?.id || 100);
}
// Fall back to bundled
return BUNDLED_WORKFLOWS[key] || null;
} }
static async getEnabledWorkflows(): Promise<WorkflowDefinition[]> { static async getEnabledWorkflows(): Promise<WorkflowDefinition[]> {
return Object.values(this.WORKFLOWS).filter(w => w.enabled); const all = await this.getAllWorkflows();
return all.filter(w => w.enabled);
} }
static async getAllWorkflows(): Promise<WorkflowDefinition[]> { static async getAllWorkflows(): Promise<WorkflowDefinition[]> {
return Object.values(this.WORKFLOWS); // Start with bundled defaults
const result: Record<string, WorkflowDefinition> = {};
for (const [key, workflow] of Object.entries(BUNDLED_WORKFLOWS)) {
result[key] = { ...workflow };
}
// Load user workflows (overwrite bundled if same key, add new ones)
const userWorkflows = listUserWorkflows();
let nextId = 100;
for (const uw of userWorkflows) {
const existingId = result[uw.key]?.id || nextId++;
result[uw.key] = userWorkflowToDefinition(uw, existingId);
}
return Object.values(result);
}
// Check if a workflow key is a bundled default
static isBundledWorkflow(key: string): boolean {
return BUNDLED_WORKFLOW_KEYS.has(key);
} }
} }
@@ -0,0 +1,144 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
export interface UserWorkflow {
key: string;
displayName: string;
description: string;
instructions: string;
enabled: boolean;
requiresFocusedNode: boolean;
}
function resolveBaseConfigDir(): string {
const override = process.env.RAH_CONFIG_DIR;
if (override && override.trim().length > 0) {
return override;
}
const home = os.homedir();
if (process.platform === 'darwin') {
return path.join(home, 'Library', 'Application Support', 'RA-H');
}
if (process.platform === 'win32') {
const roaming = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
return path.join(roaming, 'RA-H');
}
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
return path.join(xdgConfig, 'ra-h');
}
export function getWorkflowsDir(): string {
return path.join(resolveBaseConfigDir(), 'workflows');
}
function ensureWorkflowsDirExists(): void {
const dir = getWorkflowsDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
export function listUserWorkflows(): UserWorkflow[] {
const dir = getWorkflowsDir();
if (!fs.existsSync(dir)) {
return [];
}
const workflows: UserWorkflow[] = [];
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const filePath = path.join(dir, file);
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw);
// Validate required fields
if (parsed.key && parsed.displayName && parsed.instructions) {
workflows.push({
key: parsed.key,
displayName: parsed.displayName,
description: parsed.description || '',
instructions: parsed.instructions,
enabled: parsed.enabled !== false,
requiresFocusedNode: parsed.requiresFocusedNode !== false,
});
}
} catch (error) {
console.warn(`Failed to load workflow file ${file}:`, error);
}
}
return workflows;
}
export function loadUserWorkflow(key: string): UserWorkflow | null {
const filePath = path.join(getWorkflowsDir(), `${key}.json`);
if (!fs.existsSync(filePath)) {
return null;
}
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const parsed = JSON.parse(raw);
if (!parsed.key || !parsed.displayName || !parsed.instructions) {
console.warn(`Invalid workflow file for key ${key}`);
return null;
}
return {
key: parsed.key,
displayName: parsed.displayName,
description: parsed.description || '',
instructions: parsed.instructions,
enabled: parsed.enabled !== false,
requiresFocusedNode: parsed.requiresFocusedNode !== false,
};
} catch (error) {
console.warn(`Failed to load workflow ${key}:`, error);
return null;
}
}
export function saveWorkflow(workflow: UserWorkflow): void {
ensureWorkflowsDirExists();
const filePath = path.join(getWorkflowsDir(), `${workflow.key}.json`);
const data = {
key: workflow.key,
displayName: workflow.displayName,
description: workflow.description,
instructions: workflow.instructions,
enabled: workflow.enabled,
requiresFocusedNode: workflow.requiresFocusedNode,
};
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
}
export function deleteWorkflow(key: string): boolean {
const filePath = path.join(getWorkflowsDir(), `${key}.json`);
if (!fs.existsSync(filePath)) {
return false;
}
try {
fs.unlinkSync(filePath);
return true;
} catch (error) {
console.warn(`Failed to delete workflow ${key}:`, error);
return false;
}
}
export function userWorkflowExists(key: string): boolean {
const filePath = path.join(getWorkflowsDir(), `${key}.json`);
return fs.existsSync(filePath);
}
+49 -2
View File
@@ -23,6 +23,7 @@ import { executeWorkflowTool } from '../orchestration/executeWorkflow';
import { youtubeExtractTool } from '../other/youtubeExtract'; import { youtubeExtractTool } from '../other/youtubeExtract';
import { websiteExtractTool } from '../other/websiteExtract'; import { websiteExtractTool } from '../other/websiteExtract';
import { paperExtractTool } from '../other/paperExtract'; import { paperExtractTool } from '../other/paperExtract';
import { logEvalToolCall } from '@/services/evals/evalsLogger';
// Core tools available to all agents (read-only graph operations) // Core tools available to all agents (read-only graph operations)
const CORE_TOOLS: Record<string, any> = { const CORE_TOOLS: Record<string, any> = {
@@ -109,6 +110,7 @@ const PLANNER_TOOL_NAMES = [
'think', 'think',
'delegateToMiniRAH', 'delegateToMiniRAH',
'updateNode', // For workflow execution (integrate workflow needs direct write access) 'updateNode', // For workflow execution (integrate workflow needs direct write access)
'createEdge', // For Quick Link workflow
]; ];
/** /**
@@ -155,7 +157,7 @@ export function getHelperTools(availableToolNames: string[]): Record<string, any
return availableToolNames.reduce((tools, name) => { return availableToolNames.reduce((tools, name) => {
if (TOOLS[name]) { if (TOOLS[name]) {
tools[name] = TOOLS[name]; tools[name] = wrapToolForEvalLogging(name, TOOLS[name]);
} else { } else {
console.warn(`Tool '${name}' not found in registry`); console.warn(`Tool '${name}' not found in registry`);
} }
@@ -192,9 +194,23 @@ export async function executeTool(toolId: string, params: any, context: any) {
}; };
} }
const startedAt = Date.now();
try { try {
return await tool.execute(params, context); const result = await tool.execute(params, context);
logEvalToolCall({
toolName: toolId,
args: params,
result,
latencyMs: Date.now() - startedAt
});
return result;
} catch (error) { } catch (error) {
logEvalToolCall({
toolName: toolId,
args: params,
error,
latencyMs: Date.now() - startedAt
});
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : `Tool '${toolId}' execution failed`, error: error instanceof Error ? error.message : `Tool '${toolId}' execution failed`,
@@ -203,5 +219,36 @@ export async function executeTool(toolId: string, params: any, context: any) {
} }
} }
function wrapToolForEvalLogging(toolName: string, tool: any) {
if (!tool || typeof tool.execute !== 'function') {
return tool;
}
return {
...tool,
execute: async (params: any, context: any) => {
const startedAt = Date.now();
try {
const result = await tool.execute(params, context);
logEvalToolCall({
toolName,
args: params,
result,
latencyMs: Date.now() - startedAt
});
return result;
} catch (error) {
logEvalToolCall({
toolName,
args: params,
error,
latencyMs: Date.now() - startedAt
});
throw error;
}
}
};
}
// Export group utilities // Export group utilities
export { getToolGroup, groupTools, getAllToolsByGroup }; export { getToolGroup, groupTools, getAllToolsByGroup };