From 7d7b156d654576e13fa08cfe4f0e67ca555f8410 Mon Sep 17 00:00:00 2001 From: pallaoro Date: Wed, 8 Jul 2026 09:13:47 +0200 Subject: [PATCH] feat(agent): target sessions via `session` field on agent nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `do: agent` nodes could only set `agentId` → `openclaw agent --agent `. A session key placed there (e.g. `agent:main:slack:channel:agent`) can't resolve as an agent, so OpenClaw silently fell back to `main` — the agent target was effectively ignored. Add a first-class `session` field mapping to `openclaw agent --session-key`, faithful to OpenClaw's own selector model: `agentId` and `session` compose (a bare key is scoped by `agentId`; an `agent:`-prefixed key is self-scoping and must match `agentId` if both are set — enforced by OpenClaw, not re-asserted here). Validation rejects a session-key-shaped `agentId` with a hint to use `session`. Bumps to 1.3.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- openclaw.plugin.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- skills/clawflow/SKILL.md | 7 +++--- src/core/runner.ts | 18 +++++++++++---- src/core/types.ts | 13 +++++++++-- src/core/validate.ts | 2 ++ src/plugin/index.ts | 5 ++++- tests/core.test.ts | 47 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 86 insertions(+), 14 deletions(-) diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 12c4fbf..41e7676 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "clawflow", "name": "ClawFlow", "description": "The n8n for agents. Declarative, AI-native workflow engine — LLM-writable, Cloudflare-portable.", - "version": "1.2.9", + "version": "1.3.0", "skills": ["./skills/clawflow"], "activation": { "onStartup": true diff --git a/package-lock.json b/package-lock.json index 407b684..3d789f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@clawnify/clawflow", - "version": "1.2.9", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@clawnify/clawflow", - "version": "1.2.9", + "version": "1.3.0", "license": "MIT", "devDependencies": { "@types/node": "^25.5.0", diff --git a/package.json b/package.json index 4b77cb7..848ea02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@clawnify/clawflow", - "version": "1.2.9", + "version": "1.3.0", "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 508e484..1fbd6e3 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`, `tools` | +| `agent` | Delegate to a real OpenClaw agent (with tools, browser, etc.) | `task`, `agentId`, `session`, `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,7 +52,8 @@ 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 specific OpenClaw agent ID +- 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` - 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 @@ -315,7 +316,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 `. 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 `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. To run agent nodes without interactive prompts, create a dedicated `clawflow` agent with full exec access: diff --git a/src/core/runner.ts b/src/core/runner.ts index a28e6a9..e38b8e1 100644 --- a/src/core/runner.ts +++ b/src/core/runner.ts @@ -976,13 +976,14 @@ export class FlowRunner { ? parseDuration(node.timeout) : undefined; - const cliResult = await this.tryOpenClawAgent(fullPrompt, node.agentId, state, timeoutMs); + const cliResult = await this.tryOpenClawAgent(fullPrompt, node.agentId, node.session, state, timeoutMs); return { output: this.autoParseJson(cliResult) }; } private async tryOpenClawAgent( message: string, agentId: string | undefined, + session: string | undefined, state: FlowState, nodeTimeoutMs?: number, ): Promise { @@ -997,9 +998,18 @@ export class FlowRunner { throw new Error("openclaw CLI not found — agent nodes require the openclaw CLI to be installed"); } - // Resolution: node.agentId > plugin config defaultAgent > "main" - const effectiveAgent = agentId ?? this.cfg.defaultAgent ?? "main"; - const args = ["agent", "--agent", effectiveAgent, "--message", message]; + // 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); // 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. diff --git a/src/core/types.ts b/src/core/types.ts index e972ede..08fb32d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -104,10 +104,19 @@ export interface AgentNode extends BaseNode { task: string; input?: string; tools?: string[]; - /** OpenClaw agent ID to delegate to (e.g. "main", "clawflow"). Uses OpenClaw's default routing if omitted. */ + /** 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. */ 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. + */ + session?: string; } -const AGENT_KEYS = ["task", "input", "tools", "agentId"] as const; +const AGENT_KEYS = ["task", "input", "tools", "agentId", "session"] as const; const _agentCheck: CheckKeys = true; export interface BranchNode extends BaseNode { diff --git a/src/core/validate.ts b/src/core/validate.ts index 346ebfe..c6439cc 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -212,6 +212,8 @@ 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".`); break; } case "branch": { diff --git a/src/plugin/index.ts b/src/plugin/index.ts index f7bd401..b8b2cef 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -535,7 +535,10 @@ State model: Node types: ai — LLM call, structured or freeform. Use schema: for typed output. - agent — open-ended autonomous task (falls back to high-capability AI) + 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. 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 ded7b05..5a693e8 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1244,6 +1244,53 @@ describe("validateFlow", () => { const result = validateFlow(flow); assert.equal(result.ok, true); }); + + it("accepts an agent node with a session key", () => { + const flow: FlowDefinition = { + flow: "agent-session", + nodes: [{ + name: "reply", + do: "agent" as const, + task: "answer", + session: "agent:main:slack:channel:agent", + output: "r", + }] as any, + }; + const result = validateFlow(flow); + assert.equal(result.ok, true, JSON.stringify(result.errors)); + }); + + it("rejects a session-key-shaped agentId (should use session)", () => { + const flow: FlowDefinition = { + flow: "agent-bad-id", + nodes: [{ + name: "reply", + do: "agent" as const, + task: "answer", + agentId: "agent:main:slack:channel:agent", + output: "r", + }] as any, + }; + const result = validateFlow(flow); + assert.equal(result.ok, false); + assert.ok(result.errors.some((e) => e.message.includes("session-key-shaped"))); + }); + + it("allows combining agentId with a bare session key (agent scopes the key)", () => { + const flow: FlowDefinition = { + flow: "agent-both", + 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)); + }); }); // ---- Attachments (multimodal) -------------------------------------------------------