FN-7776: evaluate promptContribution.condition against effective plugin settings

Plugin prompt contributions declared a `condition` field in the SDK, but the host never evaluated it, so gated prompt content always rendered unconditionally.

- Add packages/core/src/plugin-prompt-condition.ts implementing a minimal, non-eval `settings["key"] === "value"` / `!==` condition grammar
- Wire condition evaluation into plugin-runner.ts / agent-instructions.ts / executor.ts / reviewer.ts / triage.ts / agent-heartbeat.ts so prompt contributions are filtered by effective plugin settings at each call site
- Extend plugin-types.ts and core index.ts/index.gate.ts to expose the new evaluator and condition typing
- Document the condition grammar in docs/PLUGIN_AUTHORING.md
- Add regression tests covering the evaluator and its wiring through plugin-runner and agent-instructions
- Add changeset (@runfusion/fusion minor, feature) describing the new gating behavior

Files changed:
 .changeset/fn-7776-prompt-condition.md             |  7 ++
 docs/PLUGIN_AUTHORING.md                           | 13 ++-
 .../src/__tests__/plugin-prompt-condition.test.ts  | 90 ++++++++++++++++++++
 packages/core/src/index.gate.ts                    |  6 ++
 packages/core/src/index.ts                         |  6 ++
 packages/core/src/plugin-prompt-condition.ts       | 51 +++++++++++
 packages/core/src/plugin-types.ts                  | 10 ++-
 .../src/__tests__/agent-instructions.test.ts       | 33 ++++++--
 .../engine/src/__tests__/plugin-runner.test.ts     | 98 +++++++++++++++++++++-
 packages/engine/src/agent-heartbeat.ts             |  2 +-
 packages/engine/src/agent-instructions.ts          |  6 +-
 packages/engine/src/executor.ts                    | 28 +++++--
 packages/engine/src/plugin-runner.ts               | 64 ++++++++++++--
 packages/engine/src/reviewer.ts                    |  2 +-
 packages/engine/src/triage.ts                      |  2 +-
 packages/plugin-sdk/src/index.ts                   |  4 +
 16 files changed, 385 insertions(+), 37 deletions(-)

Fusion-Task-Id: FN-7776

Fusion-Task-Lineage: ba8dcd52-260a-4166-a712-f3dd39b81b15

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 07:46:52 -07:00
parent f9641ec76e
commit 57c3d7ceb8
16 changed files with 385 additions and 37 deletions

View File

@@ -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.

View File

@@ -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"',
},
],
};

View File

@@ -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);
});
});

View File

@@ -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";

View File

@@ -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";

View File

@@ -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<string, unknown>,
): 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<string, unknown>): boolean {
return evaluatePromptConditionDetailed(condition, settings).included;
}
export function resolveEffectivePluginSettings(
stored: Record<string, unknown> | undefined,
schema?: Record<string, PluginSettingSchema>,
): Record<string, unknown> {
const effective: Record<string, unknown> = {};
for (const [key, setting] of Object.entries(schema ?? {})) {
if (Object.prototype.hasOwnProperty.call(setting, "defaultValue")) {
effective[key] = setting.defaultValue;
}
}
return { ...effective, ...(stored ?? {}) };
}

View File

@@ -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;
}

View File

@@ -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", () => {

View File

@@ -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 () => {

View File

@@ -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,
);

View File

@@ -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<string> {
if (!pluginRunner) {
return "";
}
const contributions = pluginRunner.getPromptContributionsForSurface(surface);
const contributions = await pluginRunner.getPromptContributionsForSurface(surface);
if (contributions.length === 0) {
return "";
}

View File

@@ -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.

View File

@@ -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<Array<{
pluginId: string;
contribution: PluginPromptContribution;
config: PluginPromptContributions;
}> {
return this.getPluginPromptContributions().filter(({ pluginId, contribution, config }) => {
}>> {
const settingsByPlugin = new Map<string, Record<string, unknown>>();
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<Record<string, unknown>> {
try {
const installation = await this.options.pluginStore.getPlugin(pluginId);
const loadedSchema = this.options.pluginLoader.getPlugin(pluginId)?.manifest.settingsSchema;
const schema: Record<string, PluginSettingSchema> | 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.
*/

View File

@@ -277,7 +277,7 @@ export async function reviewStep(
? buildReviewerMemoryInstructions(options.rootDir, effectiveSettings)
: "";
const reviewerPluginContributions = buildPluginPromptSection(
const reviewerPluginContributions = await buildPluginPromptSection(
"reviewer",
options.pluginRunner,
);

View File

@@ -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,
);

View File

@@ -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,