mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix(agents): load manifest-owned harnesses
This commit is contained in:
+57
-1
@@ -29,6 +29,7 @@ const resolveEmbeddedAgentStreamFnMock = vi.fn();
|
||||
const prepareCliRunContextMock = vi.fn();
|
||||
const executePreparedCliRunMock = vi.fn();
|
||||
const diagDebugMock = vi.fn();
|
||||
const ensureSelectedAgentHarnessPluginMock = vi.fn();
|
||||
|
||||
vi.mock("../llm/stream.js", async () => {
|
||||
const original = await vi.importActual<typeof import("../llm/stream.js")>("../llm/stream.js");
|
||||
@@ -119,7 +120,7 @@ vi.mock("./model-runtime-aliases.js", () => ({
|
||||
}
|
||||
}
|
||||
}
|
||||
return runtime || undefined;
|
||||
return runtime === "claude-cli" ? runtime : undefined;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -131,6 +132,11 @@ vi.mock("./cli-runner/execute.runtime.js", () => ({
|
||||
executePreparedCliRun: (...args: unknown[]) => executePreparedCliRunMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./harness/runtime-plugin.js", () => ({
|
||||
ensureSelectedAgentHarnessPlugin: (...args: unknown[]) =>
|
||||
ensureSelectedAgentHarnessPluginMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./embedded-agent-runner/runs.js", () => ({
|
||||
getActiveEmbeddedRunSnapshot: (...args: unknown[]) => getActiveEmbeddedRunSnapshotMock(...args),
|
||||
}));
|
||||
@@ -455,6 +461,7 @@ describe("runBtwSideQuestion", () => {
|
||||
prepareCliRunContextMock.mockReset();
|
||||
executePreparedCliRunMock.mockReset();
|
||||
diagDebugMock.mockReset();
|
||||
ensureSelectedAgentHarnessPluginMock.mockReset();
|
||||
clearAgentHarnesses();
|
||||
|
||||
readFileMock.mockResolvedValue("mock transcript");
|
||||
@@ -835,6 +842,55 @@ describe("runBtwSideQuestion", () => {
|
||||
expect(streamSimpleMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("loads a cold Copilot harness before selecting the /btw provider fallback", async () => {
|
||||
let loaded = false;
|
||||
ensureSelectedAgentHarnessPluginMock.mockImplementation(async () => {
|
||||
if (loaded) {
|
||||
return;
|
||||
}
|
||||
loaded = true;
|
||||
registerAgentHarness({
|
||||
id: "copilot",
|
||||
label: "Copilot test harness",
|
||||
supports: () => ({ supported: true, priority: 100 }),
|
||||
runAttempt: vi.fn(),
|
||||
});
|
||||
});
|
||||
resolveModelWithRegistryMock.mockReturnValue({
|
||||
provider: "github-copilot",
|
||||
id: "gpt-4o",
|
||||
api: "openai-completions",
|
||||
});
|
||||
mockDoneAnswer("Copilot fallback answer.");
|
||||
|
||||
const result = await runSideQuestion({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"github-copilot/gpt-4o": { agentRuntime: { id: "copilot" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
provider: "github-copilot",
|
||||
model: "gpt-4o",
|
||||
sessionKey: DEFAULT_SESSION_KEY,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ text: "Copilot fallback answer." });
|
||||
expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledOnce();
|
||||
expect(ensureSelectedAgentHarnessPluginMock).toHaveBeenCalledWith({
|
||||
provider: "github-copilot",
|
||||
modelId: "gpt-4o",
|
||||
config: expect.any(Object),
|
||||
agentId: "main",
|
||||
sessionKey: DEFAULT_SESSION_KEY,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
expect(streamSimpleMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("runs CLI-runtime alias BTW as an ephemeral CLI side question", async () => {
|
||||
const cleanup = vi.fn(async () => undefined);
|
||||
prepareCliRunContextMock.mockResolvedValueOnce({
|
||||
|
||||
+33
-14
@@ -30,6 +30,7 @@ import { EmbeddedBlockChunker, type BlockReplyChunking } from "./embedded-agent-
|
||||
import { resolveModelWithRegistry } from "./embedded-agent-runner/model.js";
|
||||
import { getActiveEmbeddedRunSnapshot } from "./embedded-agent-runner/runs.js";
|
||||
import { resolveEmbeddedAgentStreamFn } from "./embedded-agent-runner/stream-resolution.js";
|
||||
import { ensureSelectedAgentHarnessPlugin } from "./harness/runtime-plugin.js";
|
||||
import {
|
||||
resolveAvailableAgentHarnessPolicy,
|
||||
resolvePluginHarnessPolicyToolsAllow,
|
||||
@@ -479,13 +480,32 @@ export async function runBtwSideQuestion(
|
||||
config: params.cfg,
|
||||
});
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, sessionAgentId);
|
||||
const harness = selectAgentHarness({
|
||||
provider: params.provider,
|
||||
modelId: params.model,
|
||||
config: params.cfg,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
const preparedHarnesses = new Map<string, AgentHarness>();
|
||||
const prepareHarness = async (provider: string, modelId: string): Promise<AgentHarness> => {
|
||||
const key = `${provider}/${modelId}`;
|
||||
const cached = preparedHarnesses.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
await ensureSelectedAgentHarnessPlugin({
|
||||
provider,
|
||||
modelId,
|
||||
config: params.cfg,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
workspaceDir,
|
||||
});
|
||||
const harness = selectAgentHarness({
|
||||
provider,
|
||||
modelId,
|
||||
config: params.cfg,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
preparedHarnesses.set(key, harness);
|
||||
return harness;
|
||||
};
|
||||
const harness = await prepareHarness(params.provider, params.model);
|
||||
let runtimeSelection: Awaited<ReturnType<typeof resolveRuntimeModel>> | undefined;
|
||||
const resolveRuntimeSelection = async () => {
|
||||
if (!runtimeSelection) {
|
||||
@@ -647,13 +667,12 @@ export async function runBtwSideQuestion(
|
||||
}
|
||||
|
||||
const runtimeSelectionForHarness = await resolveRuntimeSelection();
|
||||
const runtimeHarness = selectAgentHarness({
|
||||
provider: runtimeSelectionForHarness.model.provider,
|
||||
modelId: runtimeSelectionForHarness.model.id,
|
||||
config: params.cfg,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
// Model resolution can canonicalize a legacy provider alias, so reselect against the resolved
|
||||
// provider/model instead of reusing the raw route's selection.
|
||||
const runtimeHarness = await prepareHarness(
|
||||
runtimeSelectionForHarness.model.provider,
|
||||
runtimeSelectionForHarness.model.id,
|
||||
);
|
||||
if (runtimeHarness.runSideQuestion) {
|
||||
return runHarnessSideQuestion(runtimeHarness, runtimeSelectionForHarness);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
|
||||
ensurePluginRegistryLoaded: vi.fn(),
|
||||
resolveActivatableProviderOwnerPluginIds: vi.fn(),
|
||||
resolveBundledProviderCompatPluginIds: vi.fn(),
|
||||
resolveManifestActivationPlan: vi.fn(),
|
||||
resolveOwningPluginIdsForProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -20,6 +21,10 @@ vi.mock("../../plugins/providers.js", () => ({
|
||||
resolveOwningPluginIdsForProviderRef: mocks.resolveOwningPluginIdsForProvider,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/activation-planner.js", () => ({
|
||||
resolveManifestActivationPlan: mocks.resolveManifestActivationPlan,
|
||||
}));
|
||||
|
||||
describe("ensureSelectedAgentHarnessPlugin", () => {
|
||||
let ensureSelectedAgentHarnessPlugin: typeof import("./runtime-plugin.js").ensureSelectedAgentHarnessPlugin;
|
||||
|
||||
@@ -27,7 +32,30 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
|
||||
mocks.ensurePluginRegistryLoaded.mockReset();
|
||||
mocks.resolveActivatableProviderOwnerPluginIds.mockReset();
|
||||
mocks.resolveBundledProviderCompatPluginIds.mockReset();
|
||||
mocks.resolveManifestActivationPlan.mockReset();
|
||||
mocks.resolveOwningPluginIdsForProvider.mockReset();
|
||||
mocks.resolveManifestActivationPlan.mockImplementation(
|
||||
({
|
||||
trigger,
|
||||
config,
|
||||
}: {
|
||||
trigger: { kind: "agentHarness"; runtime: string };
|
||||
config?: OpenClawConfig;
|
||||
}) => {
|
||||
const pluginId = trigger.runtime;
|
||||
const allow = config?.plugins?.allow ?? [];
|
||||
if (
|
||||
config?.plugins?.entries?.[pluginId]?.enabled === false ||
|
||||
(allow.length > 0 && !allow.includes(pluginId))
|
||||
) {
|
||||
return { entries: [] };
|
||||
}
|
||||
return {
|
||||
entries:
|
||||
pluginId === "codex" || pluginId === "copilot" ? [{ pluginId, origin: "bundled" }] : [],
|
||||
};
|
||||
},
|
||||
);
|
||||
mocks.resolveOwningPluginIdsForProvider.mockImplementation(
|
||||
({ provider }: { provider: string }) => (provider === "openai" ? ["openai"] : undefined),
|
||||
);
|
||||
@@ -132,6 +160,54 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("loads a manifest-owned custom harness runtime before selection", async () => {
|
||||
mocks.resolveManifestActivationPlan.mockReturnValueOnce({
|
||||
entries: [{ pluginId: "custom-harness-plugin", origin: "workspace" }],
|
||||
});
|
||||
|
||||
await ensureSelectedAgentHarnessPlugin({
|
||||
provider: "custom-provider",
|
||||
modelId: "custom-model",
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"custom-harness-plugin": { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
agentHarnessRuntimeOverride: "custom-harness",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(mocks.resolveManifestActivationPlan).toHaveBeenCalledWith({
|
||||
trigger: { kind: "agentHarness", runtime: "custom-harness" },
|
||||
config: expect.any(Object),
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope: "all",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
onlyPluginIds: ["custom-harness-plugin", "memory-core"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not activate an untrusted workspace harness from manifest metadata alone", async () => {
|
||||
mocks.resolveManifestActivationPlan.mockReturnValueOnce({
|
||||
entries: [{ pluginId: "custom-harness-plugin", origin: "workspace" }],
|
||||
});
|
||||
|
||||
await ensureSelectedAgentHarnessPlugin({
|
||||
provider: "custom-provider",
|
||||
modelId: "custom-model",
|
||||
agentHarnessRuntimeOverride: "custom-harness",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not bypass a restrictive allowlist that omits a configured Copilot harness", async () => {
|
||||
// A configured harness can request loading, but explicit plugin allowlists
|
||||
// remain the operator's boundary and are not widened implicitly.
|
||||
@@ -158,21 +234,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope: "all",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
onlyPluginIds: ["copilot"],
|
||||
config: expect.objectContaining({
|
||||
plugins: expect.objectContaining({
|
||||
allow: ["telegram"],
|
||||
entries: expect.not.objectContaining({
|
||||
copilot: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("widens a scoped harness allowlist with the provider owner for openai models", async () => {
|
||||
@@ -337,22 +399,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
|
||||
expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveBundledProviderCompatPluginIds).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveActivatableProviderOwnerPluginIds).not.toHaveBeenCalled();
|
||||
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope: "all",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
onlyPluginIds: ["codex"],
|
||||
config: expect.objectContaining({
|
||||
plugins: expect.objectContaining({
|
||||
allow: ["telegram"],
|
||||
entries: expect.not.objectContaining({
|
||||
codex: expect.anything(),
|
||||
openai: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps real bundled memory-core in a Codex scoped load when the provider has no owner plugin", async () => {
|
||||
@@ -417,5 +464,6 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
|
||||
|
||||
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveManifestActivationPlan).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
*/
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { withActivatedPluginIds } from "../../plugins/activation-context.js";
|
||||
import { resolveEffectivePluginActivationState } from "../../plugins/config-state.js";
|
||||
import { resolveManifestActivationPlan } from "../../plugins/activation-planner.js";
|
||||
import {
|
||||
normalizePluginsConfig,
|
||||
resolveEffectivePluginActivationState,
|
||||
} from "../../plugins/config-state.js";
|
||||
import { isPluginEnabledByDefaultForPlatform } from "../../plugins/default-enablement.js";
|
||||
import { hasExplicitManifestOwnerTrust } from "../../plugins/manifest-owner-policy.js";
|
||||
import {
|
||||
loadPluginRegistrySnapshot,
|
||||
normalizePluginsConfigWithRegistry,
|
||||
@@ -16,16 +21,9 @@ import {
|
||||
} from "../../plugins/providers.js";
|
||||
import { isDefaultAgentRuntimeId, OPENCLAW_AGENT_RUNTIME_ID } from "../agent-runtime-id.js";
|
||||
import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js";
|
||||
import { isCliRuntimeAliasForProvider } from "../model-runtime-aliases.js";
|
||||
import { resolveAgentHarnessPolicy } from "./policy.js";
|
||||
|
||||
/**
|
||||
* Lazy-loads plugin-backed harness runtimes before selection.
|
||||
*
|
||||
* Only cold-loadable runtimes live here; always-loaded core/openclaw runtimes should not trigger
|
||||
* plugin registry scans on every embedded-agent turn.
|
||||
*/
|
||||
const COLD_LOADABLE_HARNESS_PLUGIN_IDS = new Set(["codex", "copilot"]);
|
||||
|
||||
function dedupePluginIds(values: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
@@ -82,13 +80,35 @@ function resolveHarnessPluginIds(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir: string;
|
||||
}): string[] {
|
||||
const activationPlan = resolveManifestActivationPlan({
|
||||
trigger: { kind: "agentHarness", runtime: params.runtime },
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
const normalizedPlugins = normalizePluginsConfig(params.config?.plugins);
|
||||
const harnessPluginIds = activationPlan.entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.origin === "bundled" ||
|
||||
hasExplicitManifestOwnerTrust({
|
||||
plugin: { id: entry.pluginId },
|
||||
normalizedConfig: normalizedPlugins,
|
||||
}),
|
||||
)
|
||||
.map((entry) => entry.pluginId);
|
||||
if (harnessPluginIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (params.runtime !== "codex") {
|
||||
return [params.runtime];
|
||||
return harnessPluginIds;
|
||||
}
|
||||
if (!harnessPluginIds.includes("codex")) {
|
||||
return harnessPluginIds;
|
||||
}
|
||||
if (restrictiveAllowlistOmitsPlugin(params.config, "codex")) {
|
||||
// Respect a restrictive allowlist even when Codex would normally pull in provider owner
|
||||
// plugins. Operators who set an allowlist expect no implicit plugin expansion.
|
||||
return ["codex"];
|
||||
return harnessPluginIds;
|
||||
}
|
||||
const providerOwnerPluginIds = dedupePluginIds(
|
||||
resolveOwningPluginIdsForProviderRef({
|
||||
@@ -98,7 +118,7 @@ function resolveHarnessPluginIds(params: {
|
||||
}) ?? [],
|
||||
);
|
||||
if (providerOwnerPluginIds.length === 0) {
|
||||
return ["codex"];
|
||||
return harnessPluginIds;
|
||||
}
|
||||
const safeProviderOwnerPluginIds = dedupePluginIds([
|
||||
...resolveBundledProviderCompatPluginIds({
|
||||
@@ -114,6 +134,7 @@ function resolveHarnessPluginIds(params: {
|
||||
]);
|
||||
return dedupePluginIds([
|
||||
"codex",
|
||||
...harnessPluginIds,
|
||||
...providerOwnerPluginIds.filter(
|
||||
(pluginId) => pluginId !== "codex" && safeProviderOwnerPluginIds.includes(pluginId),
|
||||
),
|
||||
@@ -164,7 +185,11 @@ export async function ensureSelectedAgentHarnessPlugin(params: {
|
||||
if (
|
||||
isDefaultAgentRuntimeId(runtime) ||
|
||||
runtime === OPENCLAW_AGENT_RUNTIME_ID ||
|
||||
!COLD_LOADABLE_HARNESS_PLUGIN_IDS.has(runtime)
|
||||
isCliRuntimeAliasForProvider({
|
||||
runtime,
|
||||
provider: params.provider,
|
||||
cfg: params.config,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -177,6 +202,9 @@ export async function ensureSelectedAgentHarnessPlugin(params: {
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
if (pluginIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const memoryPluginIds = resolveSelectedMemoryPluginIds({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
|
||||
@@ -68,6 +68,18 @@ describe("activation planner", () => {
|
||||
hooks: [],
|
||||
origin: "bundled",
|
||||
},
|
||||
{
|
||||
id: "custom-harness-plugin",
|
||||
providers: [],
|
||||
channels: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
activation: {
|
||||
onAgentHarnesses: ["custom-harness"],
|
||||
},
|
||||
origin: "workspace",
|
||||
},
|
||||
{
|
||||
id: "demo-channel",
|
||||
channels: ["telegram"],
|
||||
@@ -145,6 +157,33 @@ describe("activation planner", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("plans manifest-owned custom harnesses and respects their activation policy", () => {
|
||||
expect(
|
||||
resolveManifestActivationPluginIds({
|
||||
trigger: {
|
||||
kind: "agentHarness",
|
||||
runtime: "custom-harness",
|
||||
},
|
||||
}),
|
||||
).toEqual(["custom-harness-plugin"]);
|
||||
|
||||
expect(
|
||||
resolveManifestActivationPluginIds({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"custom-harness-plugin": { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
trigger: {
|
||||
kind: "agentHarness",
|
||||
runtime: "custom-harness",
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps ids-only provider, agent harness, channel, and route planning stable", () => {
|
||||
expect(
|
||||
resolveManifestActivationPluginIds({
|
||||
|
||||
Reference in New Issue
Block a user