@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
JavaScript
/**
* 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