From 48c0aecb8edb869578953c57206ee1a892bf0b23 Mon Sep 17 00:00:00 2001 From: pallaoro Date: Wed, 8 Jul 2026 14:54:24 +0200 Subject: [PATCH] feat(agent): CLI-aligned selector fields, all template-resolvable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename agent-node target fields to mirror `openclaw agent` flags 1:1 — agent→--agent, sessionKey→--session-key, sessionId→--session-id, channel→--channel — keeping agentId/session as deprecated aliases (new name wins if both set; non-blocking validator warning surfaced at run time). Adds the sessionId and channel selectors. All selector fields are now interpolated, so a prior node can compute the target (e.g. agent: "{{ route.slug }}"). This is driven by a new declarative NODE_FIELD_MODES registry in types.ts: the single source of truth for how each node field is resolved, read by both the runner (template interpolation) and the validator (template-ref checks), with a compile-time exhaustiveness guard. Replaces the scattered per-handler and per-validator field lists, and fixes the gap where agentId/session were passed to the CLI un-interpolated. Bumps to 1.3.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- openclaw.plugin.json | 153 ++++++++++++++++++++++------- package-lock.json | 4 +- package.json | 2 +- skills/clawflow/SKILL.md | 18 ++-- src/core/runner.ts | 135 ++++++++++++++++++------- src/core/types.ts | 206 +++++++++++++++++++++++++++++---------- src/core/validate.ts | 103 +++++++++++++------- src/plugin/index.ts | 10 +- tests/core.test.ts | 143 +++++++++++++++++++++++++++ 9 files changed, 604 insertions(+), 170 deletions(-) diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 41e7676..e8c05d9 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,8 +2,10 @@ "id": "clawflow", "name": "ClawFlow", "description": "The n8n for agents. Declarative, AI-native workflow engine — LLM-writable, Cloudflare-portable.", - "version": "1.3.0", - "skills": ["./skills/clawflow"], + "version": "1.3.1", + "skills": [ + "./skills/clawflow" + ], "activation": { "onStartup": true }, @@ -26,59 +28,144 @@ "type": "object", "additionalProperties": false, "properties": { - "apiKey": { "type": "string" }, - "defaultModel": { "type": "string" }, - "baseUrl": { "type": "string" }, - "defaultAgent": { "type": "string" }, - "memoryDir": { "type": "string" }, - "stateDir": { "type": "string" }, - "maxNodeDurationMs": { "type": "number" }, - "defaultProvider": { "type": "string" }, + "apiKey": { + "type": "string" + }, + "defaultModel": { + "type": "string" + }, + "baseUrl": { + "type": "string" + }, + "defaultAgent": { + "type": "string" + }, + "memoryDir": { + "type": "string" + }, + "stateDir": { + "type": "string" + }, + "maxNodeDurationMs": { + "type": "number" + }, + "defaultProvider": { + "type": "string" + }, "providers": { "type": "object", "additionalProperties": { "type": "object", "additionalProperties": false, "properties": { - "baseUrl": { "type": "string" }, - "api": { "type": "string", "enum": ["openai-completions", "anthropic-messages"] }, - "apiKey": { "type": "string" }, - "apiKeyEnv": { "type": "string" }, - "models": { "type": "object", "additionalProperties": { "type": "string" } } + "baseUrl": { + "type": "string" + }, + "api": { + "type": "string", + "enum": [ + "openai-completions", + "anthropic-messages" + ] + }, + "apiKey": { + "type": "string" + }, + "apiKeyEnv": { + "type": "string" + }, + "models": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } }, - "required": ["baseUrl"] + "required": [ + "baseUrl" + ] } }, "serve": { "type": "object", "properties": { - "port": { "type": "number" }, - "path": { "type": "string" }, - "flowsDir": { "type": "string" } + "port": { + "type": "number" + }, + "path": { + "type": "string" + }, + "flowsDir": { + "type": "string" + } }, - "required": ["port"] + "required": [ + "port" + ] }, "approval": { "type": "object", "additionalProperties": false, "properties": { - "enabled": { "type": "boolean" }, - "skipSessionPatterns": { "type": "array", "items": { "type": "string" } }, - "timeoutMs": { "type": "number" }, - "timeoutBehavior": { "type": "string", "enum": ["allow", "deny"] } + "enabled": { + "type": "boolean" + }, + "skipSessionPatterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeoutMs": { + "type": "number" + }, + "timeoutBehavior": { + "type": "string", + "enum": [ + "allow", + "deny" + ] + } } } } }, "uiHints": { - "apiKey": { "label": "Anthropic API Key", "sensitive": true, "help": "Used when ANTHROPIC_API_KEY env var is not set" }, - "defaultModel": { "label": "Default AI Model", "placeholder": "smart" }, - "baseUrl": { "label": "Direct API Base URL", "placeholder": "https://api.anthropic.com" }, - "defaultAgent": { "label": "Agent ID for do:agent nodes", "placeholder": "ops", "help": "OpenClaw agent ID to delegate agent tasks to. Uses --local (embedded) if unset." }, - "memoryDir": { "label": "Memory Directory" }, - "stateDir": { "label": "Flow State Directory" }, - "maxNodeDurationMs": { "label": "Node Timeout (ms)", "placeholder": "30000" }, - "serve": { "label": "Flow HTTP Server", "help": "Start an HTTP server that runs flows on POST. Requires at least port. Endpoint: POST ///run with the JSON body as the flow's inputs." }, - "approval": { "label": "Approval Gate", "help": "Prompt the user before flow_run executes. Set enabled=false to disable entirely. Add session-key substrings to skipSessionPatterns to bypass for unattended hooks (e.g. \"email\" for inbound-email automation)." } + "apiKey": { + "label": "Anthropic API Key", + "sensitive": true, + "help": "Used when ANTHROPIC_API_KEY env var is not set" + }, + "defaultModel": { + "label": "Default AI Model", + "placeholder": "smart" + }, + "baseUrl": { + "label": "Direct API Base URL", + "placeholder": "https://api.anthropic.com" + }, + "defaultAgent": { + "label": "Agent ID for do:agent nodes", + "placeholder": "ops", + "help": "OpenClaw agent ID to delegate agent tasks to. Uses --local (embedded) if unset." + }, + "memoryDir": { + "label": "Memory Directory" + }, + "stateDir": { + "label": "Flow State Directory" + }, + "maxNodeDurationMs": { + "label": "Node Timeout (ms)", + "placeholder": "30000" + }, + "serve": { + "label": "Flow HTTP Server", + "help": "Start an HTTP server that runs flows on POST. Requires at least port. Endpoint: POST ///run with the JSON body as the flow's inputs." + }, + "approval": { + "label": "Approval Gate", + "help": "Prompt the user before flow_run executes. Set enabled=false to disable entirely. Add session-key substrings to skipSessionPatterns to bypass for unattended hooks (e.g. \"email\" for inbound-email automation)." + } } } diff --git a/package-lock.json b/package-lock.json index 3d789f6..27cfb74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@clawnify/clawflow", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@clawnify/clawflow", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "devDependencies": { "@types/node": "^25.5.0", diff --git a/package.json b/package.json index 848ea02..d48aaa6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@clawnify/clawflow", - "version": "1.3.0", + "version": "1.3.1", "description": "The n8n for agents. A declarative, AI-native workflow format that agents can read, write, and run.", "type": "module", "main": "./dist/index.js", diff --git a/skills/clawflow/SKILL.md b/skills/clawflow/SKILL.md index 1fbd6e3..9e92949 100644 --- a/skills/clawflow/SKILL.md +++ b/skills/clawflow/SKILL.md @@ -30,7 +30,7 @@ A flow is JSON with a `flow` name, an optional `env` block, and a `nodes` array. | Node | Purpose | Key fields | |------|---------|------------| | `ai` | Single LLM call, structured or freeform | `prompt`, `schema`, `model`, `input`, `attachments` | -| `agent` | Delegate to a real OpenClaw agent (with tools, browser, etc.) | `task`, `agentId`, `session`, `tools` | +| `agent` | Delegate to a real OpenClaw agent (with tools, browser, etc.) | `task`, `agent`, `sessionKey`, `sessionId`, `channel`, `tools` | | `exec` | Run a shell command deterministically (no AI) | `command`, `cwd` | | `branch` | Multi-way routing with inline sub-flows per path | `on`, `paths`, `default` | | `condition` | If/else with sub-node blocks that reconverge | `if`, `then`, `else` | @@ -52,8 +52,12 @@ A flow is JSON with a `flow` name, an optional `env` block, and a `nodes` array. - Use `do: exec` for deterministic operations (scripts, file processing, CLI tools) — never use `do: agent` for pure shell commands - Use `do: agent` for tasks that need tools (browser, exec, memory, MCP, CLI) — delegates to a real OpenClaw agent - Use `do: ai` for structured extraction and single-turn LLM calls -- Set `agentId: "clawflow"` on agent nodes to target a configured OpenClaw agent (a plain slug like `main`/`clawflow`, never a session key) -- Set `session: "agent:main:slack:channel:agent"` on agent nodes to run inside a specific existing session (e.g. a channel) — maps to `openclaw agent --session-key`. An `agent:`-prefixed key is self-scoping; a bare key (e.g. `incident-42`) is scoped by `agentId`. May be combined with `agentId` +- Agent-node selector fields mirror the `openclaw agent` CLI flags 1:1: `agent`→`--agent`, `sessionKey`→`--session-key`, `sessionId`→`--session-id`, `channel`→`--channel` (the old names `agentId`/`session` are deprecated aliases for `agent`/`sessionKey` and still work) +- Set `agent: "clawflow"` on agent nodes to target a configured OpenClaw agent (a plain slug like `main`/`clawflow`, never a session key) +- Set `sessionKey: "agent:main:slack:channel:agent"` on agent nodes to run inside a specific existing session (e.g. a channel) — maps to `--session-key`. An `agent:`-prefixed key is self-scoping; a bare key (e.g. `incident-42`) is scoped by `agent`. May be combined with `agent` +- Set `sessionId: "sess-9"` to target one explicit session by id (maps to `--session-id`) — the most specific selector, scoped by `agent`. Use `sessionKey` for channel/self-scoping keys, `sessionId` to resume a known id +- Set `channel: "slack"` to deliver the agent's reply on a specific channel (maps to `--channel`); omit to use the session's own channel +- `agent`, `sessionKey`, `sessionId`, and `channel` all support templates, so a prior node can compute the target (e.g. `agent: "{{ route.slug }}"`) - Use `do: wait` with `for: approval` before any side effects that need human review — it pauses the flow, provides a token, and shows preview data to the approver - Use `do: wait` with `for: event` to wait for external events (webhooks, signals) - `do: condition` for boolean if/else, `do: branch` for multi-way value matching — both run inline sub-flows and reconverge @@ -316,7 +320,7 @@ extract (agent) → parse (ai+schema) → loop: ### do: agent — exec approval setup -Agent nodes delegate to a real OpenClaw agent via `openclaw agent --agent --message ` (or `--session-key ` when you set `session` instead of `agentId`). By default, the spawned agent uses the "main" profile, which may prompt for exec approval on every shell command — blocking unattended flows. +Agent nodes delegate to a real OpenClaw agent via `openclaw agent --agent --message ` (or `--session-key ` when you set `sessionKey` instead of `agent`). By default, the spawned agent uses the "main" profile, which may prompt for exec approval on every shell command — blocking unattended flows. To run agent nodes without interactive prompts, create a dedicated `clawflow` agent with full exec access: @@ -364,20 +368,20 @@ openclaw approvals set --stdin <<'EOF' EOF ``` -**4. Set `agentId` on agent nodes:** +**4. Set `agent` on agent nodes:** ```json { "name": "research_topic", "do": "agent", - "agentId": "clawflow", + "agent": "clawflow", "task": "Research the latest trends in {{ inputs.topic }}", "timeout": "240s", "output": "research" } ``` -- `agentId` defaults to `"main"` if omitted (backward compatible) +- `agent` defaults to `"main"` if omitted (backward compatible) - `security: "full"` + `ask: "off"` + `askFallback: "full"` = no prompts, all exec allowed - The main interactive agent stays locked down with its own allowlist - `exec-approvals.json` is per-agent — the `clawflow` entry only affects flow-spawned runs diff --git a/src/core/runner.ts b/src/core/runner.ts index e38b8e1..f883f7c 100644 --- a/src/core/runner.ts +++ b/src/core/runner.ts @@ -26,6 +26,7 @@ import { ExecNode, ContentPart, ProviderSpec, + NODE_FIELD_MODES, parseDuration, } from "./types.js"; import { StateStore } from "./store.js"; @@ -131,6 +132,9 @@ export class FlowRunner { ): Promise { // Static validation before execution const validation = validateFlow(flow, { registry: this.registry }); + for (const w of validation.warnings) { + console.warn(`[clawflow] ${w.node ? `[${w.node}] ` : ""}${w.message}`); + } if (!validation.ok) { const id = instanceId ?? crypto.randomUUID(); const messages = validation.errors.map((e) => @@ -506,6 +510,12 @@ export class FlowRunner { output?: unknown; approve?: { token: string; prompt: string; preview?: unknown; timeout?: string }; }> { + // Interpolate every field classified as a template value in NODE_FIELD_MODES, + // once, before dispatch — so handlers never re-decide "is this field dynamic?". + // Structural fields (paths, expressions, child blocks, deep bodies) are left + // untouched here and resolved by their handler where timing matters. + node = this.resolveNodeFields(node, state); + switch (node.do) { case "ai": return this.execAi(node as AiNode, state); @@ -566,7 +576,7 @@ export class FlowRunner { const model = this.resolveModel(node.model, provider); const input = node.input ? this.getPath(state, node.input) : undefined; - const prompt = this.resolveTemplate(node.prompt, state); + const prompt = node.prompt; // template already interpolated by resolveNodeFields const jsonInstructions = node.schema ? `\n\nReturn ONLY valid JSON matching exactly this schema (no markdown, no commentary):\n${JSON.stringify(node.schema, null, 2)}` @@ -583,8 +593,8 @@ export class FlowRunner { let contentParts: ContentPart[] | undefined; if (node.attachments?.length) { contentParts = [{ type: "text", text: userText }]; - for (const raw of node.attachments) { - const resolved = this.resolveTemplate(raw, state); + for (const resolved of node.attachments) { + // paths already interpolated by resolveNodeFields (templateEach) contentParts.push(this.attachmentToContentPart(resolved)); } } @@ -965,7 +975,7 @@ export class FlowRunner { node: AgentNode, state: FlowState, ): Promise<{ output: unknown }> { - const task = this.resolveTemplate(node.task, state); + const task = node.task; // template already interpolated by resolveNodeFields const input = node.input ? this.getPath(state, node.input) : undefined; const fullPrompt = input != null @@ -976,14 +986,51 @@ export class FlowRunner { ? parseDuration(node.timeout) : undefined; - const cliResult = await this.tryOpenClawAgent(fullPrompt, node.agentId, node.session, state, timeoutMs); + const cliResult = await this.tryOpenClawAgent( + fullPrompt, + { + // new names win; agentId/session are accepted as deprecated aliases + agent: node.agent ?? node.agentId, + sessionKey: node.sessionKey ?? node.session, + sessionId: node.sessionId, + channel: node.channel, + }, + state, + timeoutMs, + ); return { output: this.autoParseJson(cliResult) }; } + // Build the `openclaw agent` selector args from a node's target fields. Mirrors + // OpenClaw's own selectors, which compose: + // --agent scopes to a configured agent + // --session-key an exact session key (agent::, or scoped to --agent) + // --session-id the most specific: one explicit session (scoped by --agent) + // --channel delivery channel for the reply + // We pass through whichever are set and let OpenClaw resolve/enforce consistency + // rather than re-asserting it here. When no target selector is set at all, fall + // back to a configured agent (defaultAgent > "main") so the call is routable. + private buildAgentArgs(target: { + agent?: string; + sessionKey?: string; + sessionId?: string; + channel?: string; + }): string[] { + const { agent, sessionKey, sessionId, channel } = target; + const args: string[] = []; + if (agent) args.push("--agent", agent); + if (sessionKey) args.push("--session-key", sessionKey); + if (sessionId) args.push("--session-id", sessionId); + if (channel) args.push("--channel", channel); + if (!agent && !sessionKey && !sessionId) { + args.push("--agent", this.cfg.defaultAgent ?? "main"); + } + return args; + } + private async tryOpenClawAgent( message: string, - agentId: string | undefined, - session: string | undefined, + target: { agent?: string; sessionKey?: string; sessionId?: string; channel?: string }, state: FlowState, nodeTimeoutMs?: number, ): Promise { @@ -998,18 +1045,7 @@ export class FlowRunner { throw new Error("openclaw CLI not found — agent nodes require the openclaw CLI to be installed"); } - // Target selection mirrors `openclaw agent`'s own selectors, which compose: - // --agent scopes to a configured agent - // --session-key selects an exact session - // Per OpenClaw: a bare session key is scoped by --agent; an "agent:"-prefixed - // key must match --agent if both are given. So we pass through whichever are - // set and let OpenClaw enforce consistency, rather than re-asserting it here. - // When neither is set, fall back to a configured agent (defaultAgent > "main"). - const args = ["agent"]; - if (agentId) args.push("--agent", agentId); - if (session) args.push("--session-key", session); - if (!agentId && !session) args.push("--agent", this.cfg.defaultAgent ?? "main"); - args.push("--message", message); + const args = ["agent", ...this.buildAgentArgs(target), "--message", message]; // Merge flow-level env vars (state.env) into the child process environment. // Set CLAWFLOW_NO_SERVE to prevent the child from binding the webhook port. @@ -1304,7 +1340,7 @@ export class FlowRunner { node: HttpNode, state: FlowState, ): Promise<{ output: unknown }> { - const url = this.resolveTemplate(node.url, state); + const url = node.url; // template already interpolated by resolveNodeFields const method = node.method ?? "GET"; let body: string | undefined; if (node.body) { @@ -1320,9 +1356,8 @@ export class FlowRunner { "Content-Type": "application/json", }; if (node.headers) { - for (const [k, v] of Object.entries(node.headers)) { - headers[k] = this.resolveTemplate(v, state); - } + // values already interpolated by resolveNodeFields (templateEach) + Object.assign(headers, node.headers); } const resp = await fetch(url, { method, headers, body }); const text = await resp.text(); @@ -1394,16 +1429,12 @@ export class FlowRunner { "flow-memory", ); fs.mkdirSync(dir, { recursive: true }); - const key = this.resolveTemplate(node.key, state).replace( - /[^a-zA-Z0-9_-]/g, - "_", - ); + // key/value templates already interpolated by resolveNodeFields + const key = node.key.replace(/[^a-zA-Z0-9_-]/g, "_"); const file = path.join(dir, `${key}.json`); if (node.action === "write") { - const value = node.value - ? this.resolveTemplate(node.value, state) - : undefined; + const value = node.value ?? undefined; fs.writeFileSync( file, JSON.stringify({ key, value, ts: Date.now() }, null, 2), @@ -1438,9 +1469,8 @@ export class FlowRunner { approve?: { token: string; prompt: string; preview?: unknown; timeout?: string }; }> { if (node.for === "approval") { - const prompt = node.prompt - ? this.resolveTemplate(node.prompt, state) - : `Approve node "${node.name}"?`; + // prompt template already interpolated by resolveNodeFields + const prompt = node.prompt ?? `Approve node "${node.name}"?`; const timeout = node.timeout ?? "24h"; const expiresAt = new Date( Date.now() + parseDuration(timeout), @@ -1711,8 +1741,9 @@ export class FlowRunner { const { promisify } = await import("util"); const execAsync = promisify(exec); - const command = this.resolveTemplate(node.command, state); - const cwd = node.cwd ? this.resolveTemplate(node.cwd, state) : undefined; + // command/cwd templates already interpolated by resolveNodeFields + const command = node.command; + const cwd = node.cwd ?? undefined; try { const { stdout, stderr } = await execAsync(command, { @@ -1743,6 +1774,40 @@ export class FlowRunner { // ---- Template helpers --------------------------------------------------------- + // Interpolate a node's `template`/`templateEach` fields against state, returning a + // shallow clone with those fields resolved (or the original node when nothing is + // dynamic). Every other mode — path, expr, children, templateDeep, code, literal — + // is passed through: its handler owns resolution, because timing (loop iteration + // state), type preservation (deep bodies), or "never interpolate" (code) matter. + // Custom steps have no entry here; execCustomStep resolves their fields itself. + private resolveNodeFields(node: FlowNode, state: FlowState): FlowNode { + const modes = NODE_FIELD_MODES[node.do]; + if (!modes) return node; + const raw = node as unknown as Record; + let clone: Record | undefined; + const patch = (field: string, value: unknown) => { + (clone ??= { ...raw })[field] = value; + }; + for (const [field, mode] of Object.entries(modes)) { + const val = raw[field]; + if (val === undefined) continue; + if (mode === "template") { + if (typeof val === "string") patch(field, this.resolveTemplate(val, state)); + } else if (mode === "templateEach") { + if (Array.isArray(val)) { + patch(field, val.map((v) => (typeof v === "string" ? this.resolveTemplate(v, state) : v))); + } else if (val !== null && typeof val === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(val)) { + out[k] = typeof v === "string" ? this.resolveTemplate(v, state) : v; + } + patch(field, out); + } + } + } + return (clone ?? raw) as unknown as FlowNode; + } + // Deep-resolve templates in an object, preserving types. // If a value is a pure template like "{{ foo.bar }}" and resolves to an // object/array, the resolved value replaces the string (no double-encoding). diff --git a/src/core/types.ts b/src/core/types.ts index 08fb32d..0869a7d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -66,16 +66,36 @@ export interface BaseNode { timeout?: string | number; // e.g. "30s", or ms integer } -// Helper: compile-time check that a key tuple matches exactly the own keys of T (minus BaseNode). -// If a key is added to an interface but not the tuple (or vice versa), this produces a type error -// on the corresponding _XCheck variable below. +// ---- Field resolution modes ----------------------------------------------------- +// Single source of truth for how each node field is treated when a flow runs. +// Both the runner (template interpolation) and the validator (template-ref checks) +// read the per-node `*_FIELD_MODES` maps below, so "which fields are dynamic" is +// declared once — not re-decided in every exec handler and again in the validator. +export type FieldMode = + // A string interpolated with {{ }} templates at exec time (resolveTemplate). + | "template" + // An array of template strings, or a map of string values. Each element is resolved. + | "templateEach" + // A string-or-object deep-resolved, type-preserving (resolveBodyObject). The owning + // handler performs this resolution; the generic pass leaves the field untouched. + | "templateDeep" + // A raw dotted state path read via getPath (never {{ }}-interpolated). + | "path" + // A JS expression evaluated against state (condition `if`). + | "expr" + // A FlowNode[] sub-block, resolved lazily as it executes (loop/branch/condition/parallel). + | "children" + // Executable source, passed through untouched and run (code `run`). + | "code" + // Passed through untouched: enums, numbers, output shapes, tool lists. + | "literal"; + +// A field-mode map must list EXACTLY the own keys of T (minus BaseNode). Adding a +// field to an interface without classifying it — or classifying one that doesn't +// exist — is a compile error. This is what stops a new value field from silently +// shipping without interpolation (the agentId/session class of bug). type OwnKeys = Exclude; -type CheckKeys = - [OwnKeys] extends [K[number]] - ? [K[number]] extends [OwnKeys] - ? true - : "Extra key(s) in tuple not on interface" - : "Missing key(s) from interface in tuple"; +type FieldModeMap = Record, FieldMode>; // ---- Node Types ----------------------------------------------------------------- @@ -96,28 +116,69 @@ export interface AiNode extends BaseNode { /** File paths (images, PDFs) to include as multimodal content. Supports templates. */ attachments?: string[]; } -const AI_KEYS = ["prompt", "input", "schema", "model", "provider", "temperature", "maxTokens", "attachments"] as const; -const _aiCheck: CheckKeys = true; +const AI_FIELD_MODES: FieldModeMap = { + prompt: "template", + input: "path", + schema: "literal", + model: "literal", + provider: "literal", + temperature: "literal", + maxTokens: "literal", + attachments: "templateEach", +}; export interface AgentNode extends BaseNode { do: "agent"; task: string; input?: string; tools?: string[]; - /** OpenClaw agent ID to delegate to (e.g. "main", "clawflow"). A plain slug, never a session key. Defaults to "main" if neither this nor `session` is set. */ + // Field names mirror `openclaw agent`'s flags 1:1 (agent→--agent, sessionKey→ + // --session-key, sessionId→--session-id, channel→--channel). `agentId`/`session` + // are the pre-1.3.1 names, kept as deprecated aliases (the new name wins if both set). + /** + * OpenClaw agent slug to delegate to (e.g. "main", "clawflow"), mapped to + * `openclaw agent --agent`. A plain slug, never a session key. Defaults to + * "main" if no target (`agent`/`sessionKey`/`sessionId`) is set. + */ + agent?: string; + /** @deprecated Renamed to `agent` (aligns with `--agent`). Still accepted; `agent` wins if both are set. */ agentId?: string; /** * OpenClaw session key to target, mapped to `openclaw agent --session-key`. * Use this to run inside a specific existing session — a channel, a named * agent session — rather than a fresh turn. "agent:"-prefixed keys (e.g. * "agent:main:slack:channel:agent") are self-scoping; a bare key (e.g. - * "incident-42") is scoped by `agentId` (or the default agent). May be - * combined with `agentId` — OpenClaw requires them to be consistent. + * "incident-42") is scoped by `agent` (or the default agent). May be + * combined with `agent` — OpenClaw requires them to be consistent. */ + sessionKey?: string; + /** @deprecated Renamed to `sessionKey` (aligns with `--session-key`). Still accepted; `sessionKey` wins if both are set. */ session?: string; + /** + * Explicit OpenClaw session id, mapped to `openclaw agent --session-id`. The + * most specific target: it names one session directly (scoped by `agent`). + * Prefer `sessionKey` for channel/self-scoping keys; use this to resume a known id. + */ + sessionId?: string; + /** + * Delivery channel for the agent's reply, mapped to `openclaw agent --channel` + * (e.g. "slack", "telegram", "whatsapp"). Omit to use the session's own channel. + */ + channel?: string; } -const AGENT_KEYS = ["task", "input", "tools", "agentId", "session"] as const; -const _agentCheck: CheckKeys = true; +const AGENT_FIELD_MODES: FieldModeMap = { + task: "template", + input: "path", + tools: "literal", + // Selector fields map to CLI flags and are interpolated so a value computed by a + // prior node (e.g. "{{ route.agent_slug }}") can target the delegate/reply channel. + agent: "template", + agentId: "template", // deprecated alias for `agent` + sessionKey: "template", + session: "template", // deprecated alias for `sessionKey` + sessionId: "template", + channel: "template", +}; export interface BranchNode extends BaseNode { do: "branch"; @@ -125,8 +186,11 @@ export interface BranchNode extends BaseNode { paths: Record; // value -> sub-flow to execute default?: FlowNode[]; // sub-flow if no path matches } -const BRANCH_KEYS = ["on", "paths", "default"] as const; -const _branchCheck: CheckKeys = true; +const BRANCH_FIELD_MODES: FieldModeMap = { + on: "path", + paths: "children", + default: "children", +}; export interface LoopNode extends BaseNode { do: "loop"; @@ -134,16 +198,21 @@ export interface LoopNode extends BaseNode { as: string; // variable name for current item nodes: FlowNode[]; } -const LOOP_KEYS = ["over", "as", "nodes"] as const; -const _loopCheck: CheckKeys = true; +const LOOP_FIELD_MODES: FieldModeMap = { + over: "path", + as: "literal", + nodes: "children", +}; export interface ParallelNode extends BaseNode { do: "parallel"; nodes: FlowNode[]; mode?: "all" | "race"; // "all" = wait for all, "race" = first wins } -const PARALLEL_KEYS = ["nodes", "mode"] as const; -const _parallelCheck: CheckKeys = true; +const PARALLEL_FIELD_MODES: FieldModeMap = { + nodes: "children", + mode: "literal", +}; export interface HttpNode extends BaseNode { do: "http"; @@ -152,8 +221,12 @@ export interface HttpNode extends BaseNode { body?: string | Record; headers?: Record; } -const HTTP_KEYS = ["url", "method", "body", "headers"] as const; -const _httpCheck: CheckKeys = true; +const HTTP_FIELD_MODES: FieldModeMap = { + url: "template", + method: "literal", + body: "templateDeep", + headers: "templateEach", +}; export interface MemoryNode extends BaseNode { do: "memory"; @@ -161,8 +234,11 @@ export interface MemoryNode extends BaseNode { key: string; value?: string; // required for write } -const MEMORY_KEYS = ["action", "key", "value"] as const; -const _memoryCheck: CheckKeys = true; +const MEMORY_FIELD_MODES: FieldModeMap = { + action: "literal", + key: "template", + value: "template", +}; /** * wait — pause for human approval or external event. @@ -191,23 +267,30 @@ export interface WaitNode extends BaseNode { event?: string; // event type to match (for: event) timeout?: string; // e.g. "24h", "5m" -- fail if exceeded } -const WAIT_KEYS = ["for", "prompt", "preview", "event"] as const; -const _waitCheck: CheckKeys = true; +const WAIT_FIELD_MODES: FieldModeMap = { + for: "literal", + prompt: "template", + preview: "templateDeep", + event: "literal", +}; export interface SleepNode extends BaseNode { do: "sleep"; duration: string; // e.g. "30s", "5m", "2h", "1d" } -const SLEEP_KEYS = ["duration"] as const; -const _sleepCheck: CheckKeys = true; +const SLEEP_FIELD_MODES: FieldModeMap = { + duration: "literal", +}; export interface CodeNode extends BaseNode { do: "code"; run: string; input?: string; } -const CODE_KEYS = ["run", "input"] as const; -const _codeCheck: CheckKeys = true; +const CODE_FIELD_MODES: FieldModeMap = { + run: "code", + input: "path", +}; /** * exec — run a shell command deterministically, no AI involved. @@ -225,8 +308,10 @@ export interface ExecNode extends BaseNode { command: string; cwd?: string; // working directory (resolved via templates) } -const EXEC_KEYS = ["command", "cwd"] as const; -const _execCheck: CheckKeys = true; +const EXEC_FIELD_MODES: FieldModeMap = { + command: "template", + cwd: "template", +}; /** * condition — if/else with sub-node blocks that reconverge. @@ -261,29 +346,44 @@ export interface ConditionNode extends BaseNode { then: FlowNode[]; // nodes to run when condition is true else?: FlowNode[]; // nodes to run when condition is false } -const CONDITION_KEYS = ["if", "then", "else"] as const; -const _conditionCheck: CheckKeys = true; +const CONDITION_FIELD_MODES: FieldModeMap = { + if: "expr", + then: "children", + else: "children", +}; -// ---- Allowed Node Keys (derived from interfaces above) -------------------------- -// Used by the validator to reject unknown fields. The ExactKeys constraint ensures -// a compile error if a key list drifts from its interface. +// ---- Field-mode registry (the single source of truth) --------------------------- +// Maps each built-in node type to its per-field resolution modes. The runner reads +// this to interpolate the right fields; the validator reads it to know which fields +// carry {{ }} templates. Add a node type here and both consumers pick it up. + +export const NODE_FIELD_MODES: Record> = { + ai: AI_FIELD_MODES, + agent: AGENT_FIELD_MODES, + branch: BRANCH_FIELD_MODES, + condition: CONDITION_FIELD_MODES, + loop: LOOP_FIELD_MODES, + parallel: PARALLEL_FIELD_MODES, + http: HTTP_FIELD_MODES, + memory: MEMORY_FIELD_MODES, + wait: WAIT_FIELD_MODES, + sleep: SLEEP_FIELD_MODES, + code: CODE_FIELD_MODES, + exec: EXEC_FIELD_MODES, +}; + +// ---- Allowed Node Keys (derived from the field-mode registry) ------------------- +// Used by the validator to reject unknown fields. Own keys come straight from each +// node's field-mode map, so the allow-list can never drift from the classification. const BASE_KEYS: readonly string[] = ["name", "do", "output", "retry", "timeout"]; -export const NODE_KEYS: Record> = { - ai: new Set([...BASE_KEYS, ...AI_KEYS]), - agent: new Set([...BASE_KEYS, ...AGENT_KEYS]), - branch: new Set([...BASE_KEYS, ...BRANCH_KEYS]), - condition: new Set([...BASE_KEYS, ...CONDITION_KEYS]), - loop: new Set([...BASE_KEYS, ...LOOP_KEYS]), - parallel: new Set([...BASE_KEYS, ...PARALLEL_KEYS]), - http: new Set([...BASE_KEYS, ...HTTP_KEYS]), - memory: new Set([...BASE_KEYS, ...MEMORY_KEYS]), - wait: new Set([...BASE_KEYS, ...WAIT_KEYS]), - sleep: new Set([...BASE_KEYS, ...SLEEP_KEYS]), - code: new Set([...BASE_KEYS, ...CODE_KEYS]), - exec: new Set([...BASE_KEYS, ...EXEC_KEYS]), -}; +export const NODE_KEYS: Record> = Object.fromEntries( + Object.entries(NODE_FIELD_MODES).map(([type, modes]) => [ + type, + new Set([...BASE_KEYS, ...Object.keys(modes)]), + ]), +); // ---- Runtime Types -------------------------------------------------------------- diff --git a/src/core/validate.ts b/src/core/validate.ts index c6439cc..733ef9d 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -1,5 +1,6 @@ import { NODE_KEYS, + NODE_FIELD_MODES, type FlowDefinition, type FlowNode, type AiNode, @@ -29,6 +30,8 @@ export interface ValidationError { export interface ValidationResult { ok: boolean; errors: ValidationError[]; + /** Non-blocking notices (e.g. deprecated field names). Do not fail the flow. */ + warnings: ValidationError[]; } export interface ValidateFlowOptions { @@ -47,9 +50,11 @@ export function validateFlow( errors.push({ message: 'Missing or invalid "flow" name' }); } + const warnings: ValidationError[] = []; + if (!Array.isArray(flow.nodes) || flow.nodes.length === 0) { errors.push({ message: "Flow must have at least one node" }); - return { ok: false, errors }; + return { ok: false, errors, warnings }; } // Validate env field if present @@ -74,7 +79,36 @@ export function validateFlow( // Walk all nodes and validate validateNodes(flow.nodes, new Set(), errors, registry); - return { ok: errors.length === 0, errors }; + // Non-blocking deprecation notices (renamed fields still work as aliases) + collectDeprecations(flow.nodes, warnings); + + return { ok: errors.length === 0, errors, warnings }; +} + +// Agent-node fields renamed in v1.3.1 to mirror the openclaw CLI flags. The old +// names still work (resolved as aliases in the runner); surface a nudge to migrate. +const DEPRECATED_AGENT_FIELDS: Record = { + agentId: "agent", + session: "sessionKey", +}; + +/** Walk the node tree and warn on deprecated field usage. */ +function collectDeprecations(nodes: FlowNode[], warnings: ValidationError[]): void { + for (const node of nodes) { + if (node.do === "agent") { + const raw = node as unknown as Record; + for (const [oldName, newName] of Object.entries(DEPRECATED_AGENT_FIELDS)) { + if (raw[oldName] !== undefined) { + warnings.push({ + node: node.name, + field: oldName, + message: `agent node "${node.name}": "${oldName}" is deprecated — use "${newName}" instead (aligns with the openclaw CLI flag). "${oldName}" still works for now.`, + }); + } + } + } + for (const child of getChildNodes(node)) collectDeprecations(child, warnings); + } } // ---- Helpers -------------------------------------------------------------------- @@ -212,8 +246,10 @@ function validateNodeFields( const n = node as AgentNode; if (!n.task) e("task", `agent node "${node.name}" requires "task"`); if ("model" in node) e("model", `agent node "${node.name}" does not support "model" — configure the model on the openclaw agent instead`); - if (typeof n.agentId === "string" && n.agentId.includes(":")) - e("agentId", `agent node "${node.name}" has a session-key-shaped "agentId" ("${n.agentId}") — "agentId" is a plain agent slug (e.g. "main"). Use "session" for a session key like "agent:main:slack:channel:agent".`); + const agentField = n.agent !== undefined ? "agent" : "agentId"; + const agentVal = n.agent ?? n.agentId; + if (typeof agentVal === "string" && agentVal.includes(":")) + e(agentField, `agent node "${node.name}" has a session-key-shaped "${agentField}" ("${agentVal}") — "${agentField}" is a plain agent slug (e.g. "main"). Use "sessionKey" for a session key like "agent:main:slack:channel:agent".`); break; } case "branch": { @@ -490,50 +526,47 @@ function validateSubFlows( } } -/** Collect all string-valued fields from a node (for template checking) */ +/** + * Collect all template-bearing string fields from a node (for {{ }} ref checking). + * + * Which fields carry templates comes from NODE_FIELD_MODES — the same registry the + * runner interpolates against — so the validator can never disagree with runtime + * about whether a field is dynamic (e.g. agent `agentId`/`session`). Modes template, + * templateEach, and templateDeep are scanned; path/expr are checked separately by + * checkStateRefs; children/code/literal carry no templates. + */ function collectStringFields( node: FlowNode, registry: StepRegistry, ): { field: string; value: string }[] { const result: { field: string; value: string }[] = []; - // Fields that contain template-interpolated strings on built-in nodes. - const builtInTemplateFields = ["prompt", "task", "url", "key", "value", "run", "body", "if", "command", "cwd", "preview"]; - // For custom-step nodes, every declared field is treated as potentially template-bearing. - const customStep = typeof node.do === "string" ? registry.get(node.do) : undefined; - const templateFields = customStep - ? Array.from(new Set([...builtInTemplateFields, ...customStep.allowedKeys])) - : builtInTemplateFields; - - for (const field of templateFields) { - const val = (node as unknown as Record)[field]; + const raw = node as unknown as Record; + + const scan = (field: string, val: unknown) => { if (typeof val === "string") { result.push({ field, value: val }); + } else if (Array.isArray(val)) { + val.forEach((v, i) => { + if (typeof v === "string") result.push({ field: `${field}[${i}]`, value: v }); + }); } else if (val !== null && typeof val === "object") { - // body can be an object with string values collectObjectStrings(val as Record, field, result); } - } + }; - // Check attachments (array of template strings) - const attachments = (node as unknown as Record).attachments; - if (Array.isArray(attachments)) { - for (let i = 0; i < attachments.length; i++) { - if (typeof attachments[i] === "string") { - result.push({ field: `attachments[${i}]`, value: attachments[i] as string }); + const modes = typeof node.do === "string" ? NODE_FIELD_MODES[node.do] : undefined; + if (modes) { + for (const [field, mode] of Object.entries(modes)) { + if (mode === "template" || mode === "templateEach" || mode === "templateDeep") { + scan(field, raw[field]); } } - } - - // Check headers - const headers = (node as unknown as Record).headers; - if (headers && typeof headers === "object") { - collectObjectStrings(headers as Record, "headers", result); - } - - // Check wait prompt - if (node.do === "wait") { - const n = node as WaitNode; - if (n.prompt) result.push({ field: "prompt", value: n.prompt }); + } else { + // Custom-step node: every declared field is treated as potentially template-bearing. + const customStep = typeof node.do === "string" ? registry.get(node.do) : undefined; + if (customStep) { + for (const field of customStep.allowedKeys) scan(field, raw[field]); + } } return result; diff --git a/src/plugin/index.ts b/src/plugin/index.ts index b8b2cef..1a91443 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -535,10 +535,12 @@ State model: Node types: ai — LLM call, structured or freeform. Use schema: for typed output. - agent — delegate to a real OpenClaw agent. Target with agentId (a configured - agent slug, e.g. "clawflow") and/or session (a session key, e.g. - "agent:main:slack:channel:agent" → --session-key). A bare session key - is scoped by agentId; an "agent:"-prefixed key is self-scoping. + agent — delegate to a real OpenClaw agent. Fields mirror the CLI flags: agent + (a configured slug, e.g. "clawflow" → --agent), sessionKey (a session + key, e.g. "agent:main:slack:channel:agent" → --session-key), sessionId + (→ --session-id), channel (→ --channel). A bare session key is scoped by + agent; an "agent:"-prefixed key is self-scoping. (agentId/session are + deprecated aliases for agent/sessionKey.) branch — route to different nodes based on a value: { on, paths, default } loop — iterate over a list: { over, as, nodes[] } parallel — run nodes concurrently: { nodes[], mode: "all"|"race" } diff --git a/tests/core.test.ts b/tests/core.test.ts index 5a693e8..7032ffa 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -236,6 +236,59 @@ describe("FlowRunner — code node diagnostics", () => { // ---- FlowRunner: exec node ------------------------------------------------------ +describe("FlowRunner — agent node field resolution", () => { + after(cleanup); + + // agent nodes shell out to the openclaw CLI, so we can't drive them end-to-end + // here. This asserts the generic pre-resolution pass (execNode → resolveNodeFields) + // interpolates the CLI-selector fields, which is the whole point of classifying + // agent/sessionKey/sessionId/channel as "template" in NODE_FIELD_MODES. + it("interpolates agent/sessionKey/sessionId/channel templates; leaves the node unmutated", () => { + const runner = new FlowRunner(cfg) as any; + const node = { + name: "reply", + do: "agent", + task: "answer {{ inputs.q }}", + agent: "{{ route.slug }}", + sessionKey: "agent:{{ route.slug }}:slack:{{ inputs.channel }}", + sessionId: "{{ route.sid }}", + channel: "{{ route.channel }}", + }; + const state = { + route: { slug: "ops", sid: "sess-9", channel: "slack" }, + inputs: { q: "hi", channel: "c123" }, + }; + const resolved = runner.resolveNodeFields(node, state); + + assert.equal(resolved.agent, "ops"); + assert.equal(resolved.sessionKey, "agent:ops:slack:c123"); + assert.equal(resolved.sessionId, "sess-9"); + assert.equal(resolved.channel, "slack"); + assert.equal(resolved.task, "answer hi"); + // original node is not mutated (flows are reused across runs) + assert.equal(node.agent, "{{ route.slug }}"); + assert.equal(node.sessionId, "{{ route.sid }}"); + }); + + it("builds openclaw agent selector args from target fields", () => { + const runner = new FlowRunner(cfg) as any; + // all selectors pass through in order; --channel is delivery, not a target + assert.deepEqual( + runner.buildAgentArgs({ agent: "ops", sessionKey: "s-key", sessionId: "s-id", channel: "slack" }), + ["--agent", "ops", "--session-key", "s-key", "--session-id", "s-id", "--channel", "slack"], + ); + // sessionKey only, no agent → key alone (openclaw scopes it) + assert.deepEqual(runner.buildAgentArgs({ sessionKey: "agent:main:slack:c1" }), [ + "--session-key", "agent:main:slack:c1", + ]); + // no target selector at all → fall back to a routable default agent + assert.deepEqual(runner.buildAgentArgs({}), ["--agent", "main"]); + assert.deepEqual(runner.buildAgentArgs({ channel: "slack" }), ["--channel", "slack", "--agent", "main"]); + // a bare sessionId is a target → no default-agent fallback + assert.deepEqual(runner.buildAgentArgs({ sessionId: "sess-9" }), ["--session-id", "sess-9"]); + }); +}); + describe("FlowRunner — exec node", () => { after(cleanup); @@ -1291,6 +1344,96 @@ describe("validateFlow", () => { const result = validateFlow(flow); assert.equal(result.ok, true, JSON.stringify(result.errors)); }); + + it("validates template refs in agentId/session (dynamic fields)", () => { + const flow: FlowDefinition = { + flow: "agent-dynamic-target", + nodes: [ + { name: "route", do: "code" as const, run: "({ slug: 'ops' })", output: "route" }, + { + name: "reply", + do: "agent" as const, + task: "answer", + agentId: "{{ route.slug }}", + session: "{{ route.slug }}-42", + output: "r", + }, + ] as any, + }; + const result = validateFlow(flow); + assert.equal(result.ok, true, JSON.stringify(result.errors)); + }); + + it("accepts an agent node with channel and sessionId selectors", () => { + const flow: FlowDefinition = { + flow: "agent-selectors", + nodes: [{ + name: "reply", + do: "agent" as const, + task: "answer", + agentId: "ops", + sessionId: "sess-9", + channel: "slack", + output: "r", + }] as any, + }; + const result = validateFlow(flow); + assert.equal(result.ok, true, JSON.stringify(result.errors)); + }); + + it("flags an undefined template ref in agentId", () => { + const flow: FlowDefinition = { + flow: "agent-bad-ref", + nodes: [{ + name: "reply", + do: "agent" as const, + task: "answer", + agentId: "{{ nope.slug }}", + output: "r", + }] as any, + }; + const result = validateFlow(flow); + assert.equal(result.ok, false); + assert.ok( + result.errors.some((e) => e.field === "agentId" && e.message.includes("nope")), + JSON.stringify(result.errors), + ); + }); + + it("accepts the new agent/sessionKey names with no deprecation warnings", () => { + const flow: FlowDefinition = { + flow: "agent-new-names", + nodes: [{ + name: "reply", + do: "agent" as const, + task: "answer", + agent: "ops", + sessionKey: "incident-42", + output: "r", + }] as any, + }; + const result = validateFlow(flow); + assert.equal(result.ok, true, JSON.stringify(result.errors)); + assert.equal(result.warnings.length, 0, JSON.stringify(result.warnings)); + }); + + it("warns (non-blocking) on deprecated agentId/session aliases", () => { + const flow: FlowDefinition = { + flow: "agent-deprecated", + nodes: [{ + name: "reply", + do: "agent" as const, + task: "answer", + agentId: "ops", + session: "incident-42", + output: "r", + }] as any, + }; + const result = validateFlow(flow); + assert.equal(result.ok, true, JSON.stringify(result.errors)); // aliases still work + assert.ok(result.warnings.some((w) => w.field === "agentId" && /deprecated/.test(w.message))); + assert.ok(result.warnings.some((w) => w.field === "session" && /deprecated/.test(w.message))); + }); }); // ---- Attachments (multimodal) -------------------------------------------------------