feat(app-core): n8n runtime-context provider — surface Discord guilds/channels + Gmail to workflow generator#7163
Conversation
…rd guilds/channels + Gmail email to the workflow generator
Registers a service of type `n8n_runtime_context_provider` so the patched
`@elizaos/plugin-n8n-workflow` (RuntimeContextProvider extension point)
can pull live connector facts into the workflow-generation prompt:
- **Discord facts**: enumerates the bot's joined guilds + their text
channels via the Discord REST API, emitting one fact line per guild
(`Discord guild "Cozy Devs" (id …) channels: #general (id …), #alerts
(id …).`). 5-minute REST cache keeps generate→modify regeneration
bursts cheap. Network failures degrade to empty facts; never block
generation.
- **Gmail fact**: surfaces the connected Gmail address so the LLM
substitutes the real value instead of `<your-email-here>`.
- **Supported credentials**: only advertises cred types that the host's
optional `credProvider.resolve()` confirms have data right now (so we
don't promise a credential the user hasn't wired up yet). Without a
credProvider, falls back to "config has connector token" heuristics.
Together with the prompt hardening shipped in plugin-n8n-workflow#25,
this closes the placeholder-id gap that previously made the LLM emit
`guildId: "={{YOUR_SERVER_ID}}"` when the runtime already knew the real
ID.
Wire-up in `runtime/eliza.ts` follows the same hot-reload pattern as
the other n8n bridges. The provider is optional from the plugin's
perspective: when not registered, the prompt simply omits the
`## Available Credentials` and `## Runtime Facts` sections.
Includes 8 unit tests (`n8n-runtime-context-provider.test.ts`).
Depends on: elizaos-plugins/plugin-n8n-workflow#25 at runtime — host
compiles fine without the plugin upgrade, but the prompt hardening only
takes effect once the plugin's RuntimeContextProvider extension point
ships.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| const MILADY_SUPPORTED_CRED_TYPES: ReadonlySet<string> = new Set([ | ||
| "discordApi", | ||
| "discordBotApi", | ||
| "discordWebhookApi", | ||
| "telegramApi", | ||
| "gmailOAuth2", | ||
| "gmailOAuth2Api", | ||
| "googleOAuth2Api", | ||
| "googleSheetsOAuth2Api", | ||
| "googleCalendarOAuth2Api", | ||
| "googleDriveOAuth2Api", | ||
| "slackApi", | ||
| "slackOAuth2Api", | ||
| ]); |
There was a problem hiding this comment.
MILADY_SUPPORTED_CRED_TYPES / CRED_TYPE_FACTS mismatch silently drops Discord webhooks and generic Google OAuth
MILADY_SUPPORTED_CRED_TYPES includes "discordWebhookApi" and "googleOAuth2Api", but neither key exists in CRED_TYPE_FACTS. In computeSupportedCredentials, the code checks MILADY_SUPPORTED_CRED_TYPES.has(credType) first and then does const meta = CRED_TYPE_FACTS[credType]; if (!meta) continue; — so both types pass the first guard but are silently skipped by the second. A Discord webhook workflow will never receive credential metadata in supportedCredentials, defeating the purpose of this service for that workflow type.
| const fetchDiscordFacts = async (botToken: string): Promise<string[]> => { | ||
| const cached = discordCache.get(botToken); | ||
| if (cached && cached.expiresAt > now()) { | ||
| return cached.facts; | ||
| } |
There was a problem hiding this comment.
Raw bot token used as cache key
discordCache.get(botToken) stores the raw Discord bot token as a Map key. While this is in-process memory and not persisted, it means the secret credential lives indefinitely as a map key (beyond its use for the actual request), surviving heap snapshots or debug tooling that iterates the map. A stable, non-secret key (e.g. a short hash of the token, or just a constant "default" since there is only ever one Discord token per config) would avoid keeping the secret in a data structure that may appear in diagnostics.
| const facts: string[] = []; | ||
| for (const guild of guilds) { | ||
| try { | ||
| const channelsRes = await fetchImpl( | ||
| `https://discord.com/api/v10/guilds/${guild.id}/channels`, | ||
| { headers }, | ||
| ); | ||
| if (!channelsRes.ok) { | ||
| facts.push( | ||
| `Discord guild "${guild.name}" (id ${guild.id}) — channels not enumerable (status ${channelsRes.status}).`, | ||
| ); | ||
| continue; | ||
| } | ||
| const channels = (await channelsRes.json()) as Array<{ | ||
| id: string; | ||
| name: string; | ||
| type: number; | ||
| }>; | ||
| // type === 0 is GUILD_TEXT, the only kind n8n's Discord node posts to. | ||
| const textChannels = channels | ||
| .filter((c) => c.type === 0) | ||
| .map((c) => `#${c.name} (${c.id})`) | ||
| .join(", "); | ||
| facts.push( | ||
| textChannels.length > 0 | ||
| ? `Discord guild "${guild.name}" (id ${guild.id}) channels: ${textChannels}.` | ||
| : `Discord guild "${guild.name}" (id ${guild.id}) — no text channels visible to the bot.`, | ||
| ); | ||
| } catch (err) { | ||
| runtime.logger.warn?.( | ||
| { | ||
| src: "n8n-runtime-context-provider", | ||
| guildId: guild.id, | ||
| err: err instanceof Error ? err.message : String(err), | ||
| }, | ||
| "Discord channels REST threw", | ||
| ); | ||
| } |
There was a problem hiding this comment.
Sequential per-guild channel fetches with no rate-limit handling
Channels are fetched guild-by-guild in a for loop, making O(n) sequential requests against the Discord REST API. Discord enforces per-route rate limits (5 requests/5 s on most guild-scoped endpoints). If the bot is a member of many guilds, a single uncached getRuntimeContext call can exhaust the rate limit for the channels endpoint and start receiving 429 responses. The !channelsRes.ok branch appends a "not enumerable" fact rather than surfacing the rate-limit condition, so the caller sees degraded data silently.
| return { | ||
| service, | ||
| stop: () => { | ||
| try { | ||
| runtime.services.delete(SERVICE_TYPE as never); | ||
| } catch { | ||
| // ignore — symmetric with other Milady bridge stop hooks | ||
| } | ||
| }, | ||
| }; |
There was a problem hiding this comment.
Handle's
stop() does not call service.stop()
The outer handle's stop() only removes the service from runtime.services. It never calls service.stop(), which is the only place discordCache.clear() is invoked. On hot-reload, ensureN8nRuntimeContextProvider calls _n8nRuntimeContextProvider.stop() (the handle's stop), so the old cache closure is orphaned rather than explicitly cleaned up. Since a new closure is created on each call this is GC-safe, but it is asymmetric with the service.stop contract.
…om supported set Both types were listed in MILADY_SUPPORTED_CRED_TYPES but had no entry in CRED_TYPE_FACTS, so they passed the first guard in computeSupportedCredentials() and then immediately dropped at the `!meta` continue. Net effect: silently empty supportedCredentials for Discord webhook workflows and generic Google OAuth (Greptile P1). Drop them from the supported set instead of inventing fact entries — Discord workflows go through the bot API path (discordBotApi) and the specific google*OAuth2Api types cover the actual nodes we surface. Comment block now flags the constraint so future additions stay in sync with CRED_TYPE_FACTS.
feat(app-core): n8n runtime-context provider — surface Discord guilds/channels + Gmail to workflow generator
Summary
Registers an optional service of type
n8n_runtime_context_providerthat the patched@elizaos/plugin-n8n-workflow(see #25) reads to inject live connector facts into the workflow-generation prompt:Discord guild \"Cozy Devs\" (id …) channels: #general (id …), #alerts (id …).). 5-minute REST cache keeps generate→modify regeneration bursts cheap.credProvider.resolve()confirms have data right now, so we don't promise a credential the user hasn't wired up yet. Without a registered credProvider, falls back to "config has connector token" heuristics.Together with the prompt hardening in plugin-n8n-workflow#25, this closes the placeholder-id gap that previously made the LLM emit
guildId: \"={{YOUR_SERVER_ID}}\"when the runtime already knew the real ID.Why now
#7134's missing-credentials banner can tell the user a credential isn't wired yet. This PR closes the next loop: when credentials are wired, the generator gets the real values up front so it doesn't emit placeholders that need post-deploy fix-up.
Changes
packages/app-core/src/services/n8n-runtime-context-provider.ts(~420 lines, self-contained —ConnectorConfigLikeand the supported-cred-types set are inlined so this doesn't drag a sibling credential-provider port along).n8n-runtime-context-provider.test.ts, 268 lines, 8 unit tests):supportedCredentialsfiltered againstcredProvider.resolve();preferredProvidersderived purely from connector config (no node search);runtime/eliza.ts:ensureN8nRuntimeContextProviderfollows the same hot-reload pattern asensureN8nAuthBridge/ensureN8nAutoStart/ensureN8nDispatchService. Picks up the optionaln8n_credential_providerif one is already registered.Backward compat
100% additive. The plugin treats the service as optional — when not registered, the prompt simply omits the new
## Available Credentialsand## Runtime Factssections (current behavior).Depends on
Test plan
bun run test packages/app-core/src/services/n8n-runtime-context-provider.test.ts— 8/8 pass.## Runtime Factsblock populated with guild + channel listing.developtoday (sections omitted).Greptile Summary
This PR introduces a new
n8n_runtime_context_providerservice that injects live Discord guild/channel IDs and Gmail addresses into the n8n workflow-generation prompt, closing the placeholder-ID gap where the LLM would previously emitguildId: \"={{YOUR_SERVER_ID}}\". The implementation follows the existing hot-reload pattern ineliza.tsand the previously-flaggedCRED_TYPE_FACTS/MILADY_SUPPORTED_CRED_TYPESmismatch has been corrected with a guard comment.Confidence Score: 5/5
Safe to merge; all previous P1 issues have been addressed and remaining findings are P2 style/consistency concerns.
All P1 issues from previous review rounds are resolved. The two new findings are both P2: a silent guild omission on channel-fetch network errors (vs. the consistent behavior on HTTP errors), and a stale comment in eliza.ts. Neither affects correctness of the happy path.
packages/app-core/src/services/n8n-runtime-context-provider.ts — the per-guild catch block (lines 291–300) should push a fallback fact line for consistency.
Important Files Changed
ensureN8nRuntimeContextProviderfollowing the established hot-reload pattern; minor: inline comment about "config has connector token" fallback does not match the actual implementation.Sequence Diagram
sequenceDiagram participant Plugin as plugin-n8n-workflow participant Provider as N8nRuntimeContextProvider participant CredProv as n8n_credential_provider participant Discord as Discord REST API participant Cache as discordCache (in-process) Plugin->>Provider: getRuntimeContext({userId, relevantNodes, relevantCredTypes}) Provider->>CredProv: resolve(userId, credType) [for each relevantCredType] CredProv-->>Provider: {status: credential_data} | {status: needs_auth} Note over Provider: Filter supportedCredentials to resolved types only alt Discord node in relevantNodes and token configured Provider->>Cache: get(botToken) alt Cache hit within 5 min TTL Cache-->>Provider: cached facts[] else Cache miss Provider->>Discord: GET /users/@me/guilds Discord-->>Provider: [{id, name}, ...] loop per guild Provider->>Discord: GET /guilds/{id}/channels Discord-->>Provider: [{id, name, type}, ...] end Provider->>Cache: set(botToken, {facts, expiresAt}) end end alt Gmail node in relevantNodes and email configured Note over Provider: Push Connected Gmail account email end Provider-->>Plugin: {supportedCredentials[], facts[]}Reviews (2): Last reviewed commit: "fix(n8n-runtime-context): drop discordWe..." | Re-trigger Greptile