UNPKG

@agentled/cli

Version:

CLI for Agentled — manage workflows, apps, and knowledge from the command line. Zero context-window cost for AI agents.

350 lines 13.5 kB
/** * Pipeline lint rules — static gotcha checks for pipeline JSON. * * Catches documented failure modes that pass JSON syntax validation but * silently misbehave at runtime. Run before `workflows validate` to skip * the API round-trip for the most common authoring mistakes. * * See docs/GOTCHAS.md (also embedded in workspace-folder.ts) for the full * write-up of each rule. */ function steps(pipeline) { const s = pipeline.steps; return Array.isArray(s) ? s : []; } function str(v) { return typeof v === 'string' ? v : JSON.stringify(v ?? ''); } function stepLabel(step) { return String(step.id ?? step.name ?? '?'); } // --------------------------------------------------------------------------- // Rules // --------------------------------------------------------------------------- function ruleConditionsVsCriteria(pipeline) { const issues = []; for (const step of steps(pipeline)) { const ec = step.entryConditions; if (ec && 'conditions' in ec) { issues.push({ severity: 'error', code: 'CRITERIA_NOT_CONDITIONS', stepId: stepLabel(step), message: 'entryConditions uses "conditions" — must be "criteria".', hint: 'Rename. The executor reads entryConditions.criteria; "conditions" is silently ignored.', }); } } return issues; } function ruleVariableVsField(pipeline) { const issues = []; for (const step of steps(pipeline)) { const ec = step.entryConditions; const criteria = ec?.criteria; if (!Array.isArray(criteria)) continue; for (const criterion of criteria) { if ('field' in criterion && !('variable' in criterion)) { issues.push({ severity: 'error', code: 'VARIABLE_NOT_FIELD', stepId: stepLabel(step), message: 'entryConditions.criteria item uses "field" — must be "variable".', hint: 'Using "field" causes the condition to be silently skipped.', }); break; } } } return issues; } function ruleGmailLabelId(pipeline) { const issues = []; for (const step of steps(pipeline)) { const app = step.app; if (!app) continue; const actionId = String(app.actionId ?? ''); if (!actionId.toUpperCase().includes('ADD_LABEL')) continue; const inputs = step.stepInputData; const labelId = str(inputs?.label_id ?? ''); if (!labelId) continue; const isTemplate = labelId.includes('{{'); const isInternalId = /^Label_/.test(labelId); if (!isTemplate && !isInternalId) { issues.push({ severity: 'error', code: 'GMAIL_LABEL_DISPLAY_NAME', stepId: stepLabel(step), message: `GMAIL_ADD_LABEL.label_id is "${labelId}" — Gmail API requires the internal Label_XXXX ID, not a display name.`, hint: 'Use {{steps.ensure-label.id}} resolved from a GMAIL_CREATE_LABEL step.', }); } } return issues; } function ruleAiWithToolsNeedsTools(pipeline) { const issues = []; for (const step of steps(pipeline)) { if (step.type !== 'aiActionWithTools') continue; const stepTools = step.tools; const agentTools = step.agent?.tools; const hasTools = (Array.isArray(stepTools) && stepTools.length > 0) || (Array.isArray(agentTools) && agentTools.length > 0); if (!hasTools) { issues.push({ severity: 'error', code: 'AI_STEP_TOOLS_REQUIRED', stepId: stepLabel(step), message: 'aiActionWithTools step has no tools in step.tools or step.agent.tools.', hint: 'Add at least one tool, e.g. { type: "builtin", builtinType: "web_search", name: "Web Search" }', }); } } return issues; } function ruleEmailStepShape(pipeline) { const issues = []; for (const step of steps(pipeline)) { if (step.type !== 'aiAction') continue; const prompt = step.pipelineStepPrompt; if (!prompt) continue; const rendererType = step.renderer?.type; const promptType = prompt.type; const hasEmailInResponse = 'email' in (prompt.responseStructure ?? {}); const isEmailStep = rendererType === 'Email' || promptType === 'email' || hasEmailInResponse; if (!isEmailStep) continue; if (promptType !== 'email') { issues.push({ severity: 'error', code: 'EMAIL_MISSING_PROMPT_TYPE', stepId: stepLabel(step), message: 'Email aiAction step is missing pipelineStepPrompt.type: "email".', hint: 'Add type: "email" — the system needs this to route the step correctly.', }); } const onApproval = step.onApproval; if (!onApproval || onApproval.action !== 'schedule-email') { issues.push({ severity: 'error', code: 'EMAIL_MISSING_SCHEDULE_ACTION', stepId: stepLabel(step), message: 'Email step is missing onApproval.action: "schedule-email" — email will never actually send.', hint: 'Add onApproval: { action: "schedule-email", executedText: "...", failedText: "..." }', }); } const inputPages = pipeline.context?.inputPages ?? []; const hasOutreachProfile = inputPages.some((page) => { const p = page; return p.pathname === 'outreach-profile' || p.configuration?.contextKey === 'outreachProfile'; }); if (!hasOutreachProfile) { issues.push({ severity: 'warning', code: 'EMAIL_MISSING_OUTREACH_PROFILE', stepId: stepLabel(step), message: 'Email step found but no outreachProfile input page in context.inputPages.', hint: 'Add an outreachProfile input page so users can configure sender name, from address, etc.', }); } } return issues; } function ruleLoopConfigOnlyFirst(pipeline) { const issues = []; const allSteps = steps(pipeline); const nextMap = new Map(); for (const step of allSteps) { const id = String(step.id ?? ''); const next = step.next?.stepId; if (id && typeof next === 'string') nextMap.set(id, next); } const stepById = new Map(allSteps.map((s) => [String(s.id ?? ''), s])); const loopStartIds = new Set(); for (const step of allSteps) if (step.loopConfig) loopStartIds.add(String(step.id ?? '')); for (const loopStartId of loopStartIds) { let cursor = nextMap.get(loopStartId); let depth = 0; while (cursor && depth < 50) { const s = stepById.get(cursor); if (!s) break; if (!s.loopConfig) break; if (String(s.id) !== loopStartId) { issues.push({ severity: 'warning', code: 'LOOP_CONFIG_MULTIPLE_STEPS', stepId: String(s.id ?? ''), message: `Step has loopConfig but a preceding step "${loopStartId}" also has loopConfig.`, hint: 'Only the first step in the loop chain should have loopConfig.', }); } cursor = nextMap.get(cursor); depth++; } } return issues; } function ruleRawInputToSearch(pipeline) { const issues = []; const SEARCH_ACTIONS = ['google_search', 'search', 'web_search', 'searchCompanies', 'search-companies']; for (const step of steps(pipeline)) { if (step.type !== 'appAction') continue; const app = step.app; if (!app) continue; const actionId = String(app.actionId ?? ''); const isSearchAction = SEARCH_ACTIONS.some((s) => actionId.toLowerCase().includes(s.toLowerCase())); if (!isSearchAction) continue; const inputs = step.stepInputData; if (!inputs) continue; for (const [key, val] of Object.entries(inputs)) { const valStr = str(val); if (/^\{\{input\.[^}]+\}\}$/.test(valStr.trim()) && ['query', 'q', 'searchQuery', 'keyword'].includes(key)) { issues.push({ severity: 'warning', code: 'RAW_INPUT_TO_SEARCH', stepId: stepLabel(step), message: `Search action "${actionId}" receives raw ${valStr} as ${key}.`, hint: 'Add an aiAction step before to generate optimized search queries.', }); } } } return issues; } function ruleChildWorkflowMilestone(pipeline) { const issues = []; const ctx = pipeline.context; const cfg = ctx?.executionInputConfig; const isInternal = cfg?.internal === true; if (!isInternal) return issues; const hasReturn = steps(pipeline).some((s) => s.type === 'return'); const hasMilestone = steps(pipeline).some((s) => s.type === 'milestone'); if (!hasReturn && hasMilestone) { issues.push({ severity: 'warning', code: 'CHILD_WORKFLOW_NO_RETURN', message: 'Workflow is marked internal:true (child) but uses milestone instead of return as terminal step.', hint: 'Replace the milestone step with type: "return" + returnConfig.fields so the parent gets outputs.', }); } return issues; } function ruleModelIdFormat(pipeline) { const issues = []; const BAD_PATTERNS = [ { pattern: /claude-sonnet-4-6/, suggest: 'claude-4-6-sonnet' }, { pattern: /claude-opus-4-7/, suggest: 'claude-4-7-opus' }, { pattern: /claude-haiku-4-5/, suggest: 'claude-4-5-haiku' }, ]; const json = JSON.stringify(pipeline ?? ''); for (const { pattern, suggest } of BAD_PATTERNS) { if (pattern.test(json)) { issues.push({ severity: 'warning', code: 'MODEL_ID_FORMAT', message: `Found model ID matching "${pattern.source}" — Agentled uses internal IDs, not Anthropic format.`, hint: `Run "agentled models list" for valid IDs. Likely correct: "${suggest}".`, }); } } return issues; } function ruleActionIdFormat(pipeline) { const issues = []; for (const step of steps(pipeline)) { if (step.type !== 'appAction') continue; const app = step.app; if (!app) continue; if (String(app.source ?? '') !== 'native') continue; const appId = String(app.id ?? ''); const actionId = String(app.actionId ?? ''); if (!appId || !actionId) continue; if (!actionId.includes('.')) { issues.push({ severity: 'warning', code: 'ACTION_ID_MISSING_PREFIX', stepId: stepLabel(step), message: `Native app action "${actionId}" should use format "${appId}.${actionId}".`, hint: 'Native app actionId must be prefixed with the appId (e.g. "kg.read-list").', }); } } return issues; } function ruleLoopCompletionCriteria(pipeline) { const issues = []; for (const step of steps(pipeline)) { const ec = step.entryConditions; const criteria = ec?.criteria; if (!Array.isArray(criteria)) continue; for (const criterion of criteria) { if (criterion.type !== 'loop_completion') continue; if (!criterion.stepId) { issues.push({ severity: 'error', code: 'LOOP_COMPLETION_MISSING_STEP_ID', stepId: stepLabel(step), message: 'loop_completion criterion is missing required "stepId" (the loop step to wait for).', }); } if (ec?.onCriteriaFail !== 'wait') { issues.push({ severity: 'warning', code: 'LOOP_COMPLETION_NOT_WAIT', stepId: stepLabel(step), message: 'loop_completion criterion should use onCriteriaFail: "wait".', hint: 'Without "wait", the step skips instead of blocking until the loop finishes.', }); } } } return issues; } const RULES = [ ruleConditionsVsCriteria, ruleVariableVsField, ruleGmailLabelId, ruleAiWithToolsNeedsTools, ruleEmailStepShape, ruleLoopConfigOnlyFirst, ruleRawInputToSearch, ruleChildWorkflowMilestone, ruleModelIdFormat, ruleActionIdFormat, ruleLoopCompletionCriteria, ]; export function lintPipeline(pipeline) { const allIssues = []; for (const rule of RULES) { try { allIssues.push(...rule(pipeline)); } catch { /* rule threw — skip silently */ } } return allIssues; } //# sourceMappingURL=pipeline-lint.js.map