diff --git a/.changeset/fn-7776-prompt-condition.md b/.changeset/fn-7776-prompt-condition.md new file mode 100644 index 0000000000..bab200d71f --- /dev/null +++ b/.changeset/fn-7776-prompt-condition.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Plugin prompt contributions can now gate content on per-project plugin settings. +category: feature +dev: PluginPromptContribution.condition is evaluated against effective plugin settings via a minimal `settings["key"] === "value"` / `!==` grammar (no eval); see docs/PLUGIN_AUTHORING.md. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index dda4fcaea7..a89c6c698d 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -1785,7 +1785,14 @@ Each contribution uses the `PluginPromptContribution` shape: - `surface`: one of the five supported surfaces - `content`: prompt text to inject - `position?`: `"append"` (default) or `"prepend"` -- `condition?`: optional human-readable condition note +- `condition?`: optional host-enforced gate evaluated against this plugin's per-project effective settings + +`condition` supports a deliberately small, injection-safe grammar: + +- `settings["key"] === "value"` +- `settings["key"] !== "value"` + +Single or double quotes are accepted for both the setting key and string literal, and whitespace around `settings`, brackets, and operators is ignored. Fusion resolves effective settings by applying each `settingsSchema` `defaultValue` first, then overlaying stored per-project plugin settings. A missing or whitespace-only condition is treated as absent and includes the contribution; malformed or unsupported conditions fail closed and exclude the contribution. Comparisons are string-strict: non-string or missing setting values are not equal to a string literal, so `===` is false and `!==` is true. ```typescript import type { PluginPromptContributions } from "@fusion/plugin-sdk"; @@ -1796,8 +1803,8 @@ const promptContributions: PluginPromptContributions = { { surface: "executor-system", position: "append", - content: "Always summarize browser-derived evidence with source URLs.", - condition: "When browser tooling is available", + content: "Prefer .NET minimal APIs for route examples.", + condition: 'settings["api-style"] === "minimal-apis"', }, ], }; diff --git a/packages/core/src/__tests__/plugin-prompt-condition.test.ts b/packages/core/src/__tests__/plugin-prompt-condition.test.ts new file mode 100644 index 0000000000..124635571a --- /dev/null +++ b/packages/core/src/__tests__/plugin-prompt-condition.test.ts @@ -0,0 +1,90 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { + evaluatePromptCondition, + evaluatePromptConditionDetailed, + resolveEffectivePluginSettings, +} from "../plugin-prompt-condition.js"; + +const settings = { + "api-style": "minimal-apis", + mode: "strict", + count: 3, + enabled: true, +}; + +describe("evaluatePromptCondition", () => { + it("includes absent and empty conditions", () => { + expect(evaluatePromptCondition(undefined, settings)).toBe(true); + expect(evaluatePromptCondition(" ", settings)).toBe(true); + }); + + it("evaluates passing and failing equality comparisons", () => { + expect(evaluatePromptCondition('settings["api-style"] === "minimal-apis"', settings)).toBe(true); + expect(evaluatePromptCondition('settings["api-style"] === "controllers"', settings)).toBe(false); + }); + + it("evaluates passing and failing inequality comparisons", () => { + expect(evaluatePromptCondition('settings["api-style"] !== "controllers"', settings)).toBe(true); + expect(evaluatePromptCondition('settings["api-style"] !== "minimal-apis"', settings)).toBe(false); + }); + + it("accepts single quotes, double quotes, and whitespace variance", () => { + expect(evaluatePromptCondition("settings['api-style'] === 'minimal-apis'", settings)).toBe(true); + expect(evaluatePromptCondition(" settings [ \"api-style\" ]=== 'minimal-apis' ", settings)).toBe(true); + expect(evaluatePromptCondition("settings['api-style'] !== \"controllers\"", settings)).toBe(true); + }); + + it("fails closed on malformed or unsupported grammar", () => { + expect(evaluatePromptConditionDetailed('settings["api-style"] == "minimal-apis"', settings)).toEqual({ + included: false, + reason: "unsupported prompt contribution condition grammar", + }); + expect(evaluatePromptCondition('settings["api-style"] === "minimal-apis" || true', settings)).toBe(false); + expect(evaluatePromptCondition('process.env.SECRET === "x"', settings)).toBe(false); + }); + + it("compares absent and non-string settings deterministically", () => { + expect(evaluatePromptCondition('settings["missing"] === "value"', settings)).toBe(false); + expect(evaluatePromptCondition('settings["missing"] !== "value"', settings)).toBe(true); + expect(evaluatePromptCondition('settings["count"] === "3"', settings)).toBe(false); + expect(evaluatePromptCondition('settings["count"] !== "3"', settings)).toBe(true); + expect(evaluatePromptCondition('settings["enabled"] === "true"', settings)).toBe(false); + }); + + it("is implemented without dynamic code execution", () => { + const source = readFileSync(new URL("../plugin-prompt-condition.ts", import.meta.url), "utf8"); + expect(source).not.toMatch(/\beval\s*\(/); + expect(source).not.toMatch(/new\s+Function\b/); + expect(source).not.toMatch(/from\s+["']node:vm["']|require\(["']node:vm["']\)|\bvm\s*\./); + }); +}); + +describe("resolveEffectivePluginSettings", () => { + it("overlays stored values over schema defaults", () => { + expect(resolveEffectivePluginSettings( + { "api-style": "minimal-apis", extra: "kept" }, + { + "api-style": { type: "enum", defaultValue: "controllers" }, + language: { type: "string", defaultValue: "csharp" }, + }, + )).toEqual({ + "api-style": "minimal-apis", + language: "csharp", + extra: "kept", + }); + }); + + it("uses default values when stored values are absent and leaves unset settings undefined", () => { + const effective = resolveEffectivePluginSettings({}, { + "api-style": { type: "enum", defaultValue: "controllers" }, + optional: { type: "string" }, + }); + + expect(effective).toEqual({ "api-style": "controllers" }); + expect(evaluatePromptCondition('settings["api-style"] === "controllers"', effective)).toBe(true); + expect(evaluatePromptCondition('settings["optional"] === "value"', effective)).toBe(false); + expect(evaluatePromptCondition('settings["optional"] !== "value"', effective)).toBe(true); + }); +}); diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index e753f9cf1d..2df3769df4 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -79,6 +79,12 @@ export type { OpenAiCodexProviderRegistration } from "./openai-models.js"; export { detectImageMimeFromBytes } from "./image-mime.js"; export type { DetectedImageMime } from "./image-mime.js"; export { redactSecrets } from "./redact-secrets.js"; +export { + evaluatePromptCondition, + evaluatePromptConditionDetailed, + resolveEffectivePluginSettings, +} from "./plugin-prompt-condition.js"; +export type { PromptConditionEvaluationResult } from "./plugin-prompt-condition.js"; export { computePlanApprovalFingerprint, resolvePlanApprovalRequired } from "./plan-approval.js"; export type { PlanApprovalMode } from "./plan-approval.js"; export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 995236d600..40b5b5dac9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,6 +35,12 @@ export type { OpenAiCodexProviderRegistration } from "./openai-models.js"; export { detectImageMimeFromBytes } from "./image-mime.js"; export type { DetectedImageMime } from "./image-mime.js"; export { redactSecrets } from "./redact-secrets.js"; +export { + evaluatePromptCondition, + evaluatePromptConditionDetailed, + resolveEffectivePluginSettings, +} from "./plugin-prompt-condition.js"; +export type { PromptConditionEvaluationResult } from "./plugin-prompt-condition.js"; export { computePlanApprovalFingerprint, resolvePlanApprovalRequired } from "./plan-approval.js"; export type { PlanApprovalMode } from "./plan-approval.js"; export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js"; diff --git a/packages/core/src/plugin-prompt-condition.ts b/packages/core/src/plugin-prompt-condition.ts new file mode 100644 index 0000000000..518953144f --- /dev/null +++ b/packages/core/src/plugin-prompt-condition.ts @@ -0,0 +1,51 @@ +import type { PluginSettingSchema } from "./plugin-types.js"; + +export interface PromptConditionEvaluationResult { + included: boolean; + reason?: string; +} + +const PROMPT_CONDITION_PATTERN = /^\s*settings\s*\[\s*(["'])([^"'\\]+)\1\s*\]\s*(===|!==)\s*(["'])([^"'\\]*)\4\s*$/; + +/** + * FNXC:PluginPrompt 2026-07-10-00:00: + * Plugin prompt conditions are plugin-authored strings that gate prompt injection, so the host must treat them as data. + * Evaluate only the documented single-comparison grammar with a deterministic regex; never use eval, Function, vm, or executable interpolation. + */ +export function evaluatePromptConditionDetailed( + condition: string | undefined, + settings: Record, +): PromptConditionEvaluationResult { + if (condition === undefined || condition.trim() === "") { + return { included: true }; + } + + const match = PROMPT_CONDITION_PATTERN.exec(condition); + if (!match) { + return { included: false, reason: "unsupported prompt contribution condition grammar" }; + } + + const [, , key, operator, , expected] = match; + const actual = settings[key]; + const equals = typeof actual === "string" && actual === expected; + return { included: operator === "===" ? equals : !equals }; +} + +export function evaluatePromptCondition(condition: string | undefined, settings: Record): boolean { + return evaluatePromptConditionDetailed(condition, settings).included; +} + +export function resolveEffectivePluginSettings( + stored: Record | undefined, + schema?: Record, +): Record { + const effective: Record = {}; + + for (const [key, setting] of Object.entries(schema ?? {})) { + if (Object.prototype.hasOwnProperty.call(setting, "defaultValue")) { + effective[key] = setting.defaultValue; + } + } + + return { ...effective, ...(stored ?? {}) }; +} diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index 671dd7c8d7..0461e2cdff 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -1012,7 +1012,15 @@ export interface PluginPromptContribution { content: string; /** Position relative to existing prompt content. Defaults to "append". */ position?: "append" | "prepend"; - /** Human-readable applicability description, reserved for future filtering. */ + /** + * Optional host-enforced gate for this contribution. + * + * FNXC:PluginPrompt 2026-07-10-00:00: + * Plugin authors can gate prompt guidance on per-project effective plugin settings, so `condition` is no longer decorative. + * The host supports exactly one injection-safe comparison: `settings["key"] === "value"` or `settings["key"] !== "value"` (single or double quotes, whitespace-tolerant). + * Settings are resolved from the plugin settings schema `defaultValue`s overlaid by stored per-project values; absent or empty conditions include the contribution, while malformed conditions fail closed and exclude it. + * Example: `settings["api-style"] === "minimal-apis"`. + */ condition?: string; } diff --git a/packages/engine/src/__tests__/agent-instructions.test.ts b/packages/engine/src/__tests__/agent-instructions.test.ts index 3bfdb4a0b6..a36891281f 100644 --- a/packages/engine/src/__tests__/agent-instructions.test.ts +++ b/packages/engine/src/__tests__/agent-instructions.test.ts @@ -631,21 +631,21 @@ describe("buildSystemPromptWithInstructions", () => { }); describe("buildPluginPromptSection", () => { - it("returns empty string when pluginRunner is undefined", () => { - expect(buildPluginPromptSection("triage", undefined)).toBe(""); + it("returns empty string when pluginRunner is undefined", async () => { + await expect(buildPluginPromptSection("triage", undefined)).resolves.toBe(""); }); - it("returns empty string when no contributions match", () => { + it("returns empty string when no contributions match", async () => { const pluginRunner = { - getPromptContributionsForSurface: vi.fn().mockReturnValue([]), + getPromptContributionsForSurface: vi.fn().mockResolvedValue([]), }; - expect(buildPluginPromptSection("triage", pluginRunner as any)).toBe(""); + await expect(buildPluginPromptSection("triage", pluginRunner as any)).resolves.toBe(""); }); - it("formats grouped plugin sections and prepend-before-append ordering", () => { + it("formats grouped plugin sections and prepend-before-append ordering", async () => { const pluginRunner = { - getPromptContributionsForSurface: vi.fn().mockReturnValue([ + getPromptContributionsForSurface: vi.fn().mockResolvedValue([ { pluginId: "plugin-b", contribution: { surface: "triage", content: "append B1" }, config: {} }, { pluginId: "plugin-a", contribution: { surface: "triage", content: "prepend A1", position: "prepend" }, config: {} }, { pluginId: "plugin-a", contribution: { surface: "triage", content: "prepend A2", position: "prepend" }, config: {} }, @@ -653,13 +653,30 @@ describe("buildPluginPromptSection", () => { ]), }; - const result = buildPluginPromptSection("triage", pluginRunner as any); + const result = await buildPluginPromptSection("triage", pluginRunner as any); expect(result).toContain("## Plugin: plugin-a\n\nprepend A1\n\nprepend A2"); expect(result).toContain("## Plugin: plugin-b\n\nappend B1"); expect(result).toContain("## Plugin: plugin-c\n\nappend C1"); expect(result.indexOf("## Plugin: plugin-a")).toBeLessThan(result.indexOf("## Plugin: plugin-b")); }); + + it("keeps passing and no-condition contributions from the condition-aware runner", async () => { + const pluginRunner = { + getPromptContributionsForSurface: vi.fn().mockResolvedValue([ + { pluginId: "plugin-a", contribution: { surface: "triage", content: "passing", condition: 'settings["mode"] === "on"' }, config: {} }, + { pluginId: "plugin-a", contribution: { surface: "triage", content: "always" }, config: {} }, + { pluginId: "plugin-b", contribution: { surface: "triage", content: "append", position: "append" }, config: {} }, + ]), + }; + + const result = await buildPluginPromptSection("triage", pluginRunner as any); + + expect(pluginRunner.getPromptContributionsForSurface).toHaveBeenCalledWith("triage"); + expect(result).toContain("passing\n\nalways"); + expect(result).toContain("## Plugin: plugin-b\n\nappend"); + expect(result).not.toContain("gated out"); + }); }); describe("diagnostics logging", () => { diff --git a/packages/engine/src/__tests__/plugin-runner.test.ts b/packages/engine/src/__tests__/plugin-runner.test.ts index 002eace407..bd74c48582 100644 --- a/packages/engine/src/__tests__/plugin-runner.test.ts +++ b/packages/engine/src/__tests__/plugin-runner.test.ts @@ -954,7 +954,7 @@ describe("PluginRunner", () => { ]); mockPluginLoader.getPlugin.mockReturnValue(createMockPlugin({ state: "started" })); await pluginRunner.init(); - const filtered = pluginRunner.getPromptContributionsForSurface("executor-system"); + const filtered = await pluginRunner.getPromptContributionsForSurface("executor-system"); expect(filtered).toHaveLength(1); expect(filtered[0].contribution.content).toBe("ok"); }); @@ -965,8 +965,100 @@ describe("PluginRunner", () => { ]); mockPluginLoader.getPlugin.mockReturnValue(createMockPlugin({ state: "started" })); await pluginRunner.init(); - expect(pluginRunner.getPromptContributionsForSurface("reviewer")).toEqual([]); - expect(pluginRunner.getPromptContributionsForSurface("executor-system")).toEqual([]); + await expect(pluginRunner.getPromptContributionsForSurface("reviewer")).resolves.toEqual([]); + await expect(pluginRunner.getPromptContributionsForSurface("executor-system")).resolves.toEqual([]); + }); + + it("evaluates prompt contribution conditions against effective plugin settings", async () => { + mockPluginLoader.getPluginPromptContributions.mockReturnValue([ + { + pluginId: "test-plugin", + contribution: { surface: "executor-system", content: "minimal API guidance", condition: 'settings["api-style"] === "minimal-apis"' }, + config: { enabledByDefault: true, contributions: [] }, + }, + { + pluginId: "test-plugin", + contribution: { surface: "executor-system", content: "always include" }, + config: { enabledByDefault: true, contributions: [] }, + }, + ]); + mockPluginLoader.getPlugin.mockReturnValue(createMockPlugin({ + state: "started", + manifest: { + id: "test-plugin", + name: "Test Plugin", + version: "1.0.0", + settingsSchema: { + "api-style": { type: "enum", enumValues: ["controllers", "minimal-apis"], defaultValue: "controllers" }, + }, + }, + })); + mockPluginStore.getPlugin.mockResolvedValue({ + id: "test-plugin", + name: "Test Plugin", + version: "1.0.0", + settings: {}, + settingsSchema: { + "api-style": { type: "enum", enumValues: ["controllers", "minimal-apis"], defaultValue: "controllers" }, + }, + }); + + await pluginRunner.init(); + await expect(pluginRunner.getPromptContributionsForSurface("executor-system")).resolves.toEqual([ + expect.objectContaining({ contribution: expect.objectContaining({ content: "always include" }) }), + ]); + + mockPluginStore.getPlugin.mockResolvedValue({ + id: "test-plugin", + name: "Test Plugin", + version: "1.0.0", + settings: { "api-style": "controllers" }, + settingsSchema: { + "api-style": { type: "enum", enumValues: ["controllers", "minimal-apis"], defaultValue: "controllers" }, + }, + }); + const controllerContributions = await pluginRunner.getPromptContributionsForSurface("executor-system"); + expect(controllerContributions.map((entry) => entry.contribution.content)).toEqual(["always include"]); + + mockPluginStore.getPlugin.mockResolvedValue({ + id: "test-plugin", + name: "Test Plugin", + version: "1.0.0", + settings: { "api-style": "minimal-apis" }, + settingsSchema: { + "api-style": { type: "enum", enumValues: ["controllers", "minimal-apis"], defaultValue: "controllers" }, + }, + }); + const minimalContributions = await pluginRunner.getPromptContributionsForSurface("executor-system"); + expect(minimalContributions.map((entry) => entry.contribution.content)).toEqual(["minimal API guidance", "always include"]); + }); + + it("keeps plugin state, surface, and enabledByDefault filters before condition evaluation", async () => { + mockPluginLoader.getPluginPromptContributions.mockReturnValue([ + { + pluginId: "test-plugin", + contribution: { surface: "executor-system", content: "wrong state", condition: 'settings["api-style"] !== "controllers"' }, + config: { enabledByDefault: true, contributions: [] }, + }, + { + pluginId: "other-plugin", + contribution: { surface: "reviewer", content: "wrong surface", condition: 'settings["api-style"] !== "controllers"' }, + config: { enabledByDefault: true, contributions: [] }, + }, + { + pluginId: "disabled-plugin", + contribution: { surface: "executor-system", content: "disabled", condition: 'settings["api-style"] !== "controllers"' }, + config: { enabledByDefault: false, contributions: [] }, + }, + ]); + mockPluginLoader.getPlugin.mockImplementation((pluginId: string) => { + if (pluginId === "test-plugin") return createMockPlugin({ state: "stopped" }); + return createMockPlugin({ manifest: { id: pluginId, name: pluginId, version: "1.0.0" }, state: "started" }); + }); + await pluginRunner.init(); + + await expect(pluginRunner.getPromptContributionsForSurface("executor-system")).resolves.toEqual([]); + expect(mockPluginStore.getPlugin).not.toHaveBeenCalled(); }); it("invalidates new contribution caches on state change and loader events", async () => { diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 8dda450ef9..886ed0d753 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -2585,7 +2585,7 @@ export class HeartbeatMonitor { } // Build structured layers for cross-session prompt caching. - const heartbeatPluginContributions = buildPluginPromptSection( + const heartbeatPluginContributions = await buildPluginPromptSection( "heartbeat", this.pluginRunner, ); diff --git a/packages/engine/src/agent-instructions.ts b/packages/engine/src/agent-instructions.ts index e58c116da6..f19a60e78f 100644 --- a/packages/engine/src/agent-instructions.ts +++ b/packages/engine/src/agent-instructions.ts @@ -441,15 +441,15 @@ export function buildSystemPromptWithInstructions( return `${basePrompt}\n\n## Custom Instructions\n\n${instructions}`; } -export function buildPluginPromptSection( +export async function buildPluginPromptSection( surface: PluginPromptSurface, pluginRunner: PluginRunner | undefined, -): string { +): Promise { if (!pluginRunner) { return ""; } - const contributions = pluginRunner.getPromptContributionsForSurface(surface); + const contributions = await pluginRunner.getPromptContributionsForSurface(surface); if (contributions.length === 0) { return ""; } diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 7c5ca77b27..357d2f3ea0 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -10639,7 +10639,7 @@ export class TaskExecutor { ?? (await this.resolveInstructionsForRole("executor", settings)); // Build structured layers for cross-session prompt caching. - const executorPluginContributions = buildPluginPromptSection( + const executorPluginContributions = await buildPluginPromptSection( "executor-system", this.options.pluginRunner, ); @@ -10791,6 +10791,7 @@ export class TaskExecutor { ].join("\n")); } else { const customFieldDefs = await this.resolveTaskCustomFieldDefs(task.id); + const pluginTaskContributions = await buildPluginPromptSection("executor-task", this.options.pluginRunner); const agentPrompt = buildExecutionPrompt( detail, this.rootDir, @@ -10799,7 +10800,10 @@ export class TaskExecutor { this.options.pluginRunner, customFieldDefs, this.workspaceConfig, - { workflowReviewGatesOwnedByGraph: this.graphCompletionInterceptors.has(task.id) }, + { + workflowReviewGatesOwnedByGraph: this.graphCompletionInterceptors.has(task.id), + pluginTaskContributions, + }, ); await promptWithFallback(session, agentPrompt); } @@ -11151,6 +11155,7 @@ export class TaskExecutor { stuckDetector?.trackTask(task.id, retrySession); const retryCustomFieldDefs = await this.resolveTaskCustomFieldDefs(task.id); + const retryPluginTaskContributions = await buildPluginPromptSection("executor-task", this.options.pluginRunner); let retryPrompt: string; if (pseudoPause.kind !== "none") { const shortMatch = (pseudoPause.matched ?? "").slice(0, 120); @@ -11178,7 +11183,10 @@ export class TaskExecutor { this.options.pluginRunner, retryCustomFieldDefs, this.workspaceConfig, - { workflowReviewGatesOwnedByGraph: this.graphCompletionInterceptors.has(task.id) }, + { + workflowReviewGatesOwnedByGraph: this.graphCompletionInterceptors.has(task.id), + pluginTaskContributions: retryPluginTaskContributions, + }, ), ].join("\n"); } else { @@ -11197,7 +11205,10 @@ export class TaskExecutor { this.options.pluginRunner, retryCustomFieldDefs, this.workspaceConfig, - { workflowReviewGatesOwnedByGraph: this.graphCompletionInterceptors.has(task.id) }, + { + workflowReviewGatesOwnedByGraph: this.graphCompletionInterceptors.has(task.id), + pluginTaskContributions: retryPluginTaskContributions, + }, ), ].join("\n"); } @@ -18119,7 +18130,7 @@ export function buildExecutionPrompt( pluginRunner?: PluginRunner, customFieldDefs?: WorkflowFieldDefinition[], workspaceConfig?: WorkspaceConfig | null, - options?: { workflowReviewGatesOwnedByGraph?: boolean }, + options?: { workflowReviewGatesOwnedByGraph?: boolean; pluginTaskContributions?: string }, ): string { const prompt = scopePromptToWorktree(task.prompt, rootDir, worktreePath, workspaceConfig); const reviewLevel = parseReviewLevelFromPrompt(prompt); @@ -18253,11 +18264,10 @@ git log --oneline customFieldsSection = lines.join("\n") + "\n"; } - const taskPromptContributions = pluginRunner?.getPromptContributionsForSurface("executor-task") ?? []; - if (taskPromptContributions.length > 0) { - executorLog.log(`${task.id}: applied ${taskPromptContributions.length} plugin prompt contributions for executor-task surface`); + const pluginTaskContributions = options?.pluginTaskContributions ?? ""; + if (pluginTaskContributions) { + executorLog.log(`${task.id}: applied plugin prompt contributions for executor-task surface`); } - const pluginTaskContributions = buildPluginPromptSection("executor-task", pluginRunner); const executionPrompt = `Execute this task. diff --git a/packages/engine/src/plugin-runner.ts b/packages/engine/src/plugin-runner.ts index a229a6c6bd..483b114f21 100644 --- a/packages/engine/src/plugin-runner.ts +++ b/packages/engine/src/plugin-runner.ts @@ -27,6 +27,7 @@ import type { PluginPromptContribution, PluginPromptContributions, PluginPromptSurface, + PluginSettingSchema, PluginSetupManifest, PluginSetupHooks, PluginSetupCheckResult, @@ -38,6 +39,8 @@ import { isAbsolute } from "node:path"; import { getTraitRegistry, getWorkflowExtensionRegistry, + evaluatePromptConditionDetailed, + resolveEffectivePluginSettings, resolveWorkflowIrForTask, workflowExtensionRegistryId, } from "@fusion/core"; @@ -848,21 +851,51 @@ export class PluginRunner { }; } - getPromptContributionsForSurface(surface: PluginPromptSurface): Array<{ + async getPromptContributionsForSurface(surface: PluginPromptSurface): Promise { - return this.getPluginPromptContributions().filter(({ pluginId, contribution, config }) => { + }>> { + const settingsByPlugin = new Map>(); + const contributions: Array<{ + pluginId: string; + contribution: PluginPromptContribution; + config: PluginPromptContributions; + }> = []; + + for (const entry of this.getPluginPromptContributions()) { + const { pluginId, contribution, config } = entry; const plugin = this.options.pluginLoader.getPlugin(pluginId); if (!plugin || plugin.state !== "started") { - return false; + continue; } if (contribution.surface !== surface) { - return false; + continue; } - return config.enabledByDefault !== false; - }); + if (config.enabledByDefault === false) { + continue; + } + + let effectiveSettings = settingsByPlugin.get(pluginId); + if (!effectiveSettings) { + effectiveSettings = await this.getEffectivePluginSettings(pluginId); + settingsByPlugin.set(pluginId, effectiveSettings); + } + + const evaluation = evaluatePromptConditionDetailed(contribution.condition, effectiveSettings); + if (!evaluation.included) { + if (evaluation.reason) { + this.log.warn(`Excluded prompt contribution for plugin ${pluginId} on surface ${surface}: ${evaluation.reason}`); + } else if (process.env.DEBUG?.includes("plugins")) { + this.log.log(`Excluded prompt contribution for plugin ${pluginId} on surface ${surface}: condition evaluated false`); + } + continue; + } + + contributions.push(entry); + } + + return contributions; } /** @@ -1246,6 +1279,23 @@ export class PluginRunner { } } + /** + * FNXC:PluginPrompt 2026-07-10-00:00: + * Prompt contribution conditions must evaluate against per-project effective settings, not raw stored overrides. + * Read each plugin installation once per assembly and layer schema defaults under stored values so settings-panel defaults can gate prompt guidance. + */ + private async getEffectivePluginSettings(pluginId: string): Promise> { + try { + const installation = await this.options.pluginStore.getPlugin(pluginId); + const loadedSchema = this.options.pluginLoader.getPlugin(pluginId)?.manifest.settingsSchema; + const schema: Record | undefined = installation.settingsSchema ?? loadedSchema; + return resolveEffectivePluginSettings(installation.settings, schema); + } catch (err) { + this.log.warn(`Failed to get effective settings for plugin ${pluginId}: ${err instanceof Error ? err.message : String(err)}`); + return {}; + } + } + /** * Create a logger for a plugin. */ diff --git a/packages/engine/src/reviewer.ts b/packages/engine/src/reviewer.ts index 0d7c7fe5cd..5c974c13ac 100644 --- a/packages/engine/src/reviewer.ts +++ b/packages/engine/src/reviewer.ts @@ -277,7 +277,7 @@ export async function reviewStep( ? buildReviewerMemoryInstructions(options.rootDir, effectiveSettings) : ""; - const reviewerPluginContributions = buildPluginPromptSection( + const reviewerPluginContributions = await buildPluginPromptSection( "reviewer", options.pluginRunner, ); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index f1a81b2467..5f4cc49a11 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -1043,7 +1043,7 @@ export class TriageProcessor { ? `## Identity\n\nYou are ${assignedAgent.name}${assignedAgent.title?.trim() ? `, ${assignedAgent.title.trim()}` : ""} (agent ID: ${assignedAgent.id}, role: ${assignedAgent.role}).` : ""; // Build structured layers for cross-session prompt caching. - const triagePluginContributions = buildPluginPromptSection( + const triagePluginContributions = await buildPluginPromptSection( "triage", this.options.pluginRunner, ); diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 6b75c5db48..ae14449a0a 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -112,6 +112,10 @@ export type { AutoMergeFactProviderResult, AutoMergeFactProviderHandler, PluginPromptSurface, + /** + * FNXC:PluginPrompt 2026-07-10-00:00: + * Re-export the core-authored PluginPromptContribution type so SDK consumers see the enforced `condition` grammar in generated declarations. + */ PluginPromptContribution, PluginPromptContributions, ExecutorRuntimeTaskContext,