Skip to content

Commit cafdf62

Browse files
committed
feat(app-core,n8n): n8n runtime-context provider — surface real Discord 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.
1 parent 2f998a4 commit cafdf62

3 files changed

Lines changed: 747 additions & 0 deletions

File tree

packages/app-core/src/runtime/eliza.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,14 @@ async function repairRuntimeAfterBoot(
513513
// triggers can dispatch immediately on first emit.
514514
await ensureTriggerEventBridge(runtime);
515515

516+
// Register the n8n runtime-context provider so the patched
517+
// `@elizaos/plugin-n8n-workflow` can pull real Discord guild/channel IDs
518+
// and the user's Gmail email into the workflow-generation prompt — closing
519+
// the placeholder + missing-credentials-block gaps. The plugin treats this
520+
// service as advisory; if it isn't registered the prompt simply omits the
521+
// facts/credentials sections.
522+
await ensureN8nRuntimeContextProvider(runtime);
523+
516524
return runtime;
517525
}
518526

@@ -539,6 +547,11 @@ let _n8nDispatch: { execute: (workflowId: string) => Promise<unknown> } | null =
539547
// event bus.
540548
let _triggerEventBridge: { stop: () => void } | null = null;
541549

550+
// Module-level handle for the n8n runtime-context provider. Reset across
551+
// hot-reloads so the previous closure (capturing an outdated config getter)
552+
// does not survive into the fresh runtime's services map.
553+
let _n8nRuntimeContextProvider: { stop: () => void } | null = null;
554+
542555
async function ensureN8nAuthBridge(runtime: AgentRuntime): Promise<void> {
543556
if (_n8nAuthBridge) {
544557
try {
@@ -660,6 +673,56 @@ async function ensureTriggerEventBridge(runtime: AgentRuntime): Promise<void> {
660673
}
661674
}
662675

676+
async function ensureN8nRuntimeContextProvider(
677+
runtime: AgentRuntime,
678+
): Promise<void> {
679+
if (_n8nRuntimeContextProvider) {
680+
try {
681+
_n8nRuntimeContextProvider.stop();
682+
} catch {
683+
/* ignore */
684+
}
685+
_n8nRuntimeContextProvider = null;
686+
}
687+
try {
688+
const { startMiladyN8nRuntimeContextProvider } = await import(
689+
"../services/n8n-runtime-context-provider.js"
690+
);
691+
// If a sibling `n8n_credential_provider` is registered (Milady ships one
692+
// separately), reach into the runtime services map for its `resolve` so
693+
// the context provider can filter `supportedCredentials` to types that
694+
// actually have data right now. Optional — without it the context
695+
// provider falls back to "config has connector token" heuristics.
696+
const credEntries =
697+
runtime.services.get("n8n_credential_provider" as never) ?? [];
698+
const credProviderInstance = credEntries[0] as
699+
| {
700+
resolve?: (
701+
userId: string,
702+
credType: string,
703+
) => Promise<unknown>;
704+
}
705+
| undefined;
706+
const credProvider =
707+
credProviderInstance && typeof credProviderInstance.resolve === "function"
708+
? (credProviderInstance as Parameters<
709+
typeof startMiladyN8nRuntimeContextProvider
710+
>[1]["credProvider"])
711+
: undefined;
712+
_n8nRuntimeContextProvider = startMiladyN8nRuntimeContextProvider(runtime, {
713+
getConfig: () => loadElizaConfig(),
714+
credProvider,
715+
});
716+
logger.info("[eliza] n8n runtime-context provider registered");
717+
} catch (err) {
718+
logger.warn(
719+
`[eliza] Failed to register n8n runtime-context provider: ${
720+
err instanceof Error ? err.message : String(err)
721+
}`,
722+
);
723+
}
724+
}
725+
663726
// Module-level Telegraf bot reference for lifecycle management across restarts.
664727
let _telegramBot: { stop: (reason?: string) => void } | null = null;
665728

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import type { AgentRuntime } from "@elizaos/core";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import {
5+
type ConnectorConfigLike,
6+
N8N_RUNTIME_CONTEXT_PROVIDER_SERVICE_TYPE,
7+
startMiladyN8nRuntimeContextProvider,
8+
} from "./n8n-runtime-context-provider";
9+
10+
const USER_ID = "00000000-0000-0000-0000-000000000001";
11+
12+
function makeRuntime(): AgentRuntime {
13+
const services = new Map<string, unknown[]>();
14+
const logger = {
15+
info: vi.fn(),
16+
warn: vi.fn(),
17+
error: vi.fn(),
18+
debug: vi.fn(),
19+
};
20+
return {
21+
services,
22+
logger,
23+
} as unknown as AgentRuntime;
24+
}
25+
26+
function makeConfig(overrides: ConnectorConfigLike = {}): ConnectorConfigLike {
27+
return {
28+
connectors: {
29+
...(overrides.connectors ?? {}),
30+
},
31+
};
32+
}
33+
34+
/**
35+
* Plugin's `NodeDefinition.credentials` shape, minimally typed for tests.
36+
*/
37+
const DISCORD_NODE = {
38+
name: "n8n-nodes-base.discord",
39+
displayName: "Discord",
40+
credentials: [{ name: "discordApi", required: true }],
41+
} as const;
42+
43+
const GMAIL_NODE = {
44+
name: "n8n-nodes-base.gmail",
45+
displayName: "Gmail",
46+
credentials: [{ name: "gmailOAuth2", required: true }],
47+
} as const;
48+
49+
describe("startMiladyN8nRuntimeContextProvider", () => {
50+
let runtime: AgentRuntime;
51+
52+
beforeEach(() => {
53+
runtime = makeRuntime();
54+
});
55+
56+
afterEach(() => {
57+
vi.clearAllMocks();
58+
});
59+
60+
it("registers itself under n8n_runtime_context_provider on construction", () => {
61+
startMiladyN8nRuntimeContextProvider(runtime, {
62+
getConfig: () => makeConfig(),
63+
});
64+
const instances = runtime.services.get(
65+
N8N_RUNTIME_CONTEXT_PROVIDER_SERVICE_TYPE as never,
66+
);
67+
expect(instances).toBeDefined();
68+
expect(instances?.length).toBe(1);
69+
expect(
70+
typeof (instances?.[0] as { getRuntimeContext: unknown })
71+
.getRuntimeContext,
72+
).toBe("function");
73+
});
74+
75+
it("emits empty facts when no connector config and no credProvider injected — but still lists architecturally supported cred types", async () => {
76+
// Without a credProvider, the context provider can't filter by what's
77+
// actually resolvable, so it falls back to MILADY_SUPPORTED_CRED_TYPES.
78+
// That's the right call: the LLM should still attach the credentials
79+
// block — failure to resolve at deploy time surfaces a clear `needs_auth`
80+
// error, while omitting the block silently is what we're trying to fix.
81+
const handle = startMiladyN8nRuntimeContextProvider(runtime, {
82+
getConfig: () => makeConfig(),
83+
});
84+
const ctx = await handle.service.getRuntimeContext({
85+
userId: USER_ID,
86+
relevantNodes: [DISCORD_NODE],
87+
relevantCredTypes: ["discordApi"],
88+
});
89+
expect(ctx.facts).toEqual([]);
90+
expect(ctx.supportedCredentials.map((c) => c.credType)).toEqual([
91+
"discordApi",
92+
]);
93+
});
94+
95+
it("emits one fact per Discord guild with channels enumerated", async () => {
96+
const config = makeConfig({
97+
connectors: { discord: { token: "discord-bot-token" } },
98+
});
99+
const fetchImpl = vi.fn(async (url: string) => {
100+
if (typeof url === "string" && url.endsWith("/users/@me/guilds")) {
101+
return {
102+
ok: true,
103+
status: 200,
104+
json: async () => [
105+
{ id: "guild1", name: "2PM" },
106+
{ id: "guild2", name: "TestServer" },
107+
],
108+
} as unknown as Response;
109+
}
110+
if (typeof url === "string" && url.includes("/guilds/guild1/channels")) {
111+
return {
112+
ok: true,
113+
status: 200,
114+
json: async () => [
115+
{ id: "chan-general", name: "general", type: 0 },
116+
{ id: "chan-voice", name: "voice", type: 2 },
117+
],
118+
} as unknown as Response;
119+
}
120+
if (typeof url === "string" && url.includes("/guilds/guild2/channels")) {
121+
return {
122+
ok: true,
123+
status: 200,
124+
json: async () => [
125+
{ id: "chan-other", name: "other-text", type: 0 },
126+
],
127+
} as unknown as Response;
128+
}
129+
throw new Error(`unexpected fetch ${url}`);
130+
});
131+
const handle = startMiladyN8nRuntimeContextProvider(runtime, {
132+
getConfig: () => config,
133+
fetchImpl: fetchImpl as unknown as typeof fetch,
134+
});
135+
const ctx = await handle.service.getRuntimeContext({
136+
userId: USER_ID,
137+
relevantNodes: [DISCORD_NODE],
138+
relevantCredTypes: ["discordApi"],
139+
});
140+
expect(ctx.facts).toHaveLength(2);
141+
expect(ctx.facts[0]).toContain('Discord guild "2PM"');
142+
expect(ctx.facts[0]).toContain("guild1");
143+
expect(ctx.facts[0]).toContain("#general (chan-general)");
144+
expect(ctx.facts[0]).not.toContain("voice"); // type !== 0 filtered
145+
expect(ctx.facts[1]).toContain('Discord guild "TestServer"');
146+
expect(ctx.facts[1]).toContain("#other-text (chan-other)");
147+
});
148+
149+
it("emits gmail email fact when configured and a gmail node is in scope", async () => {
150+
const config = makeConfig({
151+
connectors: { gmail: { email: "user@example.com" } },
152+
});
153+
const handle = startMiladyN8nRuntimeContextProvider(runtime, {
154+
getConfig: () => config,
155+
});
156+
const ctx = await handle.service.getRuntimeContext({
157+
userId: USER_ID,
158+
relevantNodes: [GMAIL_NODE],
159+
relevantCredTypes: ["gmailOAuth2"],
160+
});
161+
expect(ctx.facts).toEqual(["Connected Gmail account: user@example.com."]);
162+
});
163+
164+
it("filters supportedCredentials by what the cred provider can actually resolve", async () => {
165+
const credProvider = {
166+
resolve: vi.fn(async (_userId: string, credType: string) => {
167+
if (credType === "discordApi") {
168+
return {
169+
status: "credential_data" as const,
170+
data: { botToken: "x" },
171+
};
172+
}
173+
return {
174+
status: "needs_auth" as const,
175+
authUrl: "milady://settings/connectors/gmail",
176+
};
177+
}),
178+
};
179+
const handle = startMiladyN8nRuntimeContextProvider(runtime, {
180+
getConfig: () => makeConfig(),
181+
credProvider,
182+
});
183+
const ctx = await handle.service.getRuntimeContext({
184+
userId: USER_ID,
185+
relevantNodes: [DISCORD_NODE, GMAIL_NODE],
186+
relevantCredTypes: ["discordApi", "gmailOAuth2"],
187+
});
188+
expect(ctx.supportedCredentials.map((c) => c.credType)).toEqual([
189+
"discordApi",
190+
]);
191+
expect(credProvider.resolve).toHaveBeenCalledWith(USER_ID, "discordApi");
192+
expect(credProvider.resolve).toHaveBeenCalledWith(USER_ID, "gmailOAuth2");
193+
});
194+
195+
it("swallows network failures and returns empty facts", async () => {
196+
const config = makeConfig({
197+
connectors: { discord: { token: "discord-bot-token" } },
198+
});
199+
const fetchImpl = vi.fn(async () => {
200+
throw new Error("network down");
201+
});
202+
const handle = startMiladyN8nRuntimeContextProvider(runtime, {
203+
getConfig: () => config,
204+
fetchImpl: fetchImpl as unknown as typeof fetch,
205+
});
206+
const ctx = await handle.service.getRuntimeContext({
207+
userId: USER_ID,
208+
relevantNodes: [DISCORD_NODE],
209+
relevantCredTypes: ["discordApi"],
210+
});
211+
expect(ctx.facts).toEqual([]);
212+
});
213+
214+
it("does not query Discord REST when no Discord node is in scope", async () => {
215+
const config = makeConfig({
216+
connectors: { discord: { token: "discord-bot-token" } },
217+
});
218+
const fetchImpl = vi.fn();
219+
const handle = startMiladyN8nRuntimeContextProvider(runtime, {
220+
getConfig: () => config,
221+
fetchImpl: fetchImpl as unknown as typeof fetch,
222+
});
223+
const ctx = await handle.service.getRuntimeContext({
224+
userId: USER_ID,
225+
relevantNodes: [GMAIL_NODE],
226+
relevantCredTypes: ["gmailOAuth2"],
227+
});
228+
expect(fetchImpl).not.toHaveBeenCalled();
229+
expect(ctx.facts).toEqual([]);
230+
});
231+
232+
it("caches Discord REST responses across consecutive calls", async () => {
233+
const config = makeConfig({
234+
connectors: { discord: { token: "tok" } },
235+
});
236+
const fetchImpl = vi.fn(async (url: string) => {
237+
if (typeof url === "string" && url.endsWith("/users/@me/guilds")) {
238+
return {
239+
ok: true,
240+
status: 200,
241+
json: async () => [{ id: "g", name: "G" }],
242+
} as unknown as Response;
243+
}
244+
return {
245+
ok: true,
246+
status: 200,
247+
json: async () => [],
248+
} as unknown as Response;
249+
});
250+
const handle = startMiladyN8nRuntimeContextProvider(runtime, {
251+
getConfig: () => config,
252+
fetchImpl: fetchImpl as unknown as typeof fetch,
253+
});
254+
await handle.service.getRuntimeContext({
255+
userId: USER_ID,
256+
relevantNodes: [DISCORD_NODE],
257+
relevantCredTypes: ["discordApi"],
258+
});
259+
const firstCallCount = fetchImpl.mock.calls.length;
260+
expect(firstCallCount).toBeGreaterThan(0);
261+
await handle.service.getRuntimeContext({
262+
userId: USER_ID,
263+
relevantNodes: [DISCORD_NODE],
264+
relevantCredTypes: ["discordApi"],
265+
});
266+
expect(fetchImpl.mock.calls.length).toBe(firstCallCount);
267+
});
268+
});

0 commit comments

Comments
 (0)