diff --git a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs index b6ab680b8..432104917 100644 --- a/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs +++ b/src/openhuman/flows/agents/workflow_builder/builder_prompt.rs @@ -400,6 +400,68 @@ mod tests { ask the user for their member id in one question rather than \ guessing a channel" ); + + // Positive: non-owner DM resolution — the prompt must teach the + // builder to resolve a NAMED recipient who is NOT the connected + // owner via a lookup node, not just the owner's own + // `platform_user_id`. This guidance must be PLATFORM-AGNOSTIC (no + // toolkit-specific slug hardcoded) — the same shape applies to + // Slack, Discord, Telegram, or any other messaging toolkit. + assert!( + STANDING_PROMPT.contains("is NOT the connected"), + "standing prompt must teach the non-owner DM case explicitly" + ); + assert!( + STANDING_PROMPT.contains("platform-agnostic"), + "standing prompt must state the non-owner DM guidance is \ + platform-agnostic, not tied to one toolkit" + ); + assert!( + STANDING_PROMPT.contains("search_tool_catalog { query, toolkit }"), + "standing prompt must teach resolving the lookup action via \ + search_tool_catalog scoped to the TARGET toolkit, rather than \ + hardcoding one platform's slug" + ); + assert!( + STANDING_PROMPT.contains("tool_call` node upstream of the send"), + "standing prompt must teach wiring the lookup as a tool_call \ + node upstream of the send node" + ); + assert!( + STANDING_PROMPT.contains("resolves to exactly one match"), + "standing prompt must require a name search to resolve to \ + exactly one match before binding it without asking" + ); + assert!( + STANDING_PROMPT.contains("ask the user to confirm which person"), + "standing prompt must preserve the safety rule: never message an \ + unverified same-name match, ask instead when ambiguous" + ); + assert!( + STANDING_PROMPT.contains("Check the send action") + && STANDING_PROMPT.contains("open conversation"), + "standing prompt must teach checking the send tool's own contract \ + for a required open-conversation step, handled generally via the \ + contract rather than a single-platform special case" + ); + + // Negative: none of the non-owner DM guidance may hardcode a + // toolkit-specific action slug or arg name — the reviewer flagged an + // earlier draft of this guidance as Slack-only, which violates the + // platform-agnostic rule. + for banned in [ + "SLACK_FIND_USERS", + "SLACK_LIST_ALL_USERS", + "config.args.email", + "exact_match", + ] { + assert!( + !STANDING_PROMPT.contains(banned), + "standing prompt's non-owner DM guidance must not hardcode \ + the platform-specific `{banned}` — it must stay \ + platform-agnostic (any messaging toolkit)" + ); + } } #[test] diff --git a/src/openhuman/flows/agents/workflow_builder/prompt.md b/src/openhuman/flows/agents/workflow_builder/prompt.md index 8b8de71ac..79076686f 100644 --- a/src/openhuman/flows/agents/workflow_builder/prompt.md +++ b/src/openhuman/flows/agents/workflow_builder/prompt.md @@ -623,6 +623,48 @@ into exactly one bucket before you write the node: `#channel` name) — no need to ask. Only if `platform_user_id` is null for that connection, ask the user for their member id in ONE concise question rather than guessing a channel. + - "DM ``" / "message ``" where `` is NOT the connected + owner (no matching `platform_user_id`) → you don't have their platform + user id up front, and guessing one is unsafe. This shape is + **platform-agnostic** — it applies the same way whether the + destination toolkit is Slack, Discord, Telegram, or any other + messaging app. Don't ask immediately — resolve it: + 1. `search_tool_catalog { query, toolkit }` scoped to the TARGET + toolkit to find its user-lookup action — a "find user" / "lookup by + email" / "list users" style action, whatever that platform exposes + (never assume a slug across toolkits; always search for it). + 2. Wire that lookup as a **`tool_call` node upstream of the send**. + 3. Prefer an **email / exact lookup** when the platform offers one — + that's unambiguous, so bind its result directly with no question. + A **name search** can return multiple people: only bind it straight + through when it resolves to exactly one match; otherwise this is + bucket 3 — **ask the user to confirm which person / their email** + rather than messaging an unverified same-name match. If the + toolkit's lookup action can't resolve the person by name or email at + all, fall back to its "list users" style action plus a downstream + `transform`/`code` filter on an identifying field (email/display + name/etc). + 4. Bind the resolved id into the send node's recipient arg with an `=` + expression off the lookup node — use `get_tool_contract` to find the + exact output field and confirm with `dry_run_workflow` rather than + guessing — same as the owner path above. + 5. **Check the send action's own `get_tool_contract` for a required + "open conversation" step first.** Some messaging toolkits require + opening/creating a DM conversation for a user id before you can send + to it; others accept a user id as the recipient directly and + open/reuse the DM automatically. Never assume either way — if the + contract names a separate open/create-conversation action as a + prerequisite, wire that `tool_call` too, between the lookup and the + send. + + Worked example (illustrative, not tied to one platform) — "every + Monday at 9am, message alan@acme.com his open tickets": `trigger` + (schedule, Mon 09:00) → `tool_call` `find_alan` (the target toolkit's + user-lookup action, args grounded via `get_tool_contract`, e.g. an + `email` arg) → `tool_call` fetching the tickets → (an + open-conversation `tool_call` first, only if that toolkit's contract + requires one) → `tool_call` `dm_alan` (the toolkit's send action, + recipient arg bound to `=nodes.find_alan.item.json.data.`). - Exactly one connected account for the toolkit the step needs → that account (`list_flow_connections` / `composio_list_connections` tell you this; don't ask "which Gmail?" when there's only one).