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:
7
.changeset/fn-7776-prompt-condition.md
Normal file
7
.changeset/fn-7776-prompt-condition.md
Normal 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.
|
||||
@@ -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"',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
90
packages/core/src/__tests__/plugin-prompt-condition.test.ts
Normal file
90
packages/core/src/__tests__/plugin-prompt-condition.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
51
packages/core/src/plugin-prompt-condition.ts
Normal file
51
packages/core/src/plugin-prompt-condition.ts
Normal 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 ?? {}) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -277,7 +277,7 @@ export async function reviewStep(
|
||||
? buildReviewerMemoryInstructions(options.rootDir, effectiveSettings)
|
||||
: "";
|
||||
|
||||
const reviewerPluginContributions = buildPluginPromptSection(
|
||||
const reviewerPluginContributions = await buildPluginPromptSection(
|
||||
"reviewer",
|
||||
options.pluginRunner,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user