Files
openclaw/extensions/llm-task/doctor-contract-api.ts
Peter Steinberger 6192673da4 perf(doctor): delete the heavy doctor barrel and finish slimming enumeration (#120882)
* refactor(plugin-sdk): delete the heavy runtime-doctor barrel

Nothing may pull the state-db/kysely graph through a doctor barrel anymore.
The barrel's remaining heavy exports move to two narrow private-local
subpaths, each with a single purpose:

- doctor-repair-runtime: install-path diagnosis, plugin config removal, and
  state-database schema detect/repair (matrix doctor, voice-call lazy import)
- plugin-state-store-runtime: the sync keyed-store factory. It stays out of
  plugin-state-runtime because hot channel entrypoints import that at module
  load and opening a store pulls the state-database graph.

Doctor closures also stop pulling ssrf-runtime (fetch-guard + gateway net)
for two legacy private-network helpers that live in the lighter ssrf-policy
subpath: mattermost, nextcloud-talk, tlon, matrix.

The closure guard now forbids the two new heavy subpaths instead of the
deleted barrel, so the invariant keeps being enforced where it still applies.

* perf(doctor): keep heavy graphs out of every doctor closure

Doctor enumeration cold-loads each declaring plugin's contract closure, so
one heavy import in a closure is paid by the whole sweep. Four barrels were
still dragging unrelated graphs in for trivial helpers; each is repaired at
the leaf rather than by caching downstream:

- Legacy private-network config migration moves to a config leaf. It only
  reshapes records, but lived beside the SSRF runtime (DNS, proxy, logging),
  costing mattermost ~2.7s. ssrf-policy re-exports it, surface unchanged.
- Streaming config readers move to a leaf. They read two config keys, but
  streaming.ts also formats tool aggregates, pulling tool-display/logging/
  acp-core; that cost slack ~2.3s.
- signal took the channel-secret barrel for isRecord; the canonical plugin
  record guard is string-coerce-runtime (root AGENTS.md).
- llm-task took the provider-model barrel for parseModelRef, now a narrow
  model-ref-parse subpath.

Full doctor enumeration of all 42 declaring plugins, built mode:
legacy config rules 6668ms -> 1265ms, state migrations 184ms -> 127ms.
No plugin remains an outlier; the slowest is now ~380ms against a ~200ms floor.

Public export surfaces of every touched SDK subpath are byte-identical
(verified by diffing built module exports before/after); the API baseline
hashes move only because re-exported declarations emit differently.

The closure guard gains rules for each repaired barrel so the invariant
holds for future closures.

* fix(release): exclude new private-local declarations from the published package

Same pack-path rule as c41da3759f: private-local subpaths ship without d.ts.

* fix(doctor): repair the closure guard violations that break main

The landed guard fails on main: three closures import heavy barrels for one
symbol each. Two more surfaced once the guard learned about the provider-model
barrel. Each gets a narrow subpath at the leaf:

- telegram sent-message-cache + state-migrations took the session-store barrel
  (session accessor + state-db) for resolveStorePath -> session-store-paths
- discord thread-bindings.state took the channel-outbound barrel (reply
  pipeline + channel registry) for one identity write -> outbound-echo-runtime
- discord model-picker took the provider-model barrel for normalizeProviderId,
  which model-ref-parse now exposes beside parseModelRef

The guard also stops walking artifacts of plugins whose manifest declares no
doctor surface. Such a declaration gates the artifact off every enumeration
path exactly as resolvePluginDoctorContracts does, so its closure cost is never
paid; anthropic ("doctorContract": {}) was being held to a cost it cannot
incur. Absent declarations still load eagerly and stay enforced.

Side effect worth naming: discord's built doctor contract now loads again.
On main both discord and telegram fail to require in packaged builds (an
ESM-only transitive dep) and silently lose their repairs; this restores
discord and takes enumerated legacy config rules from 87 to 99. Telegram's
built artifact still pulls execa through dist chunking - a build-level defect
with a different owner, filed as follow-up.
2026-08-08 22:01:44 -07:00

111 lines
4.0 KiB
TypeScript

// LLM Task doctor contract migrates shipped plugin-local completion policy.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { parseModelRef } from "openclaw/plugin-sdk/model-ref-parse";
import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor-migrations";
const ENTRY_PATH = "plugins.entries.llm-task";
function preserveLiteralLegacyModelRefs(values: string[]): string[] {
return values.filter((value) => {
if (value !== value.trim() || value === "*") {
return false;
}
const normalized = parseModelRef(value, "");
if (!normalized) {
return false;
}
return `${normalized.provider}/${normalized.model}` === value;
});
}
export const legacyConfigRules = [
{
path: ["plugins", "entries", "llm-task", "config", "allowedModels"],
message: `${ENTRY_PATH}.config.allowedModels moved to ${ENTRY_PATH}.llm.allowedCompletionModels. Run "openclaw doctor --fix".`,
},
{
path: ["plugins", "entries", "llm-task"],
message: `${ENTRY_PATH} needs host-owned LLM model/profile permissions to preserve shipped tool parameters. Run "openclaw doctor --fix".`,
match: (value: unknown) => {
const entry = asObjectRecord(value);
const llm = asObjectRecord(entry?.llm);
return llm?.allowModelOverride === undefined || llm.allowAuthProfileOverride === undefined;
},
},
];
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
config: OpenClawConfig;
changes: string[];
} {
const plugins = asObjectRecord(cfg.plugins);
const entries = asObjectRecord(plugins?.entries);
const entry = asObjectRecord(entries?.["llm-task"]);
if (!entry) {
return { config: cfg, changes: [] };
}
const pluginConfig = asObjectRecord(entry.config) ?? {};
const hadLegacyAllowedModels = Object.hasOwn(pluginConfig, "allowedModels");
const legacyAllowedModelsValue = pluginConfig.allowedModels;
const legacyAllowedModels = Array.isArray(legacyAllowedModelsValue)
? legacyAllowedModelsValue.filter((value): value is string => typeof value === "string")
: undefined;
const migratedAllowedModels = !hadLegacyAllowedModels
? undefined
: !Array.isArray(legacyAllowedModelsValue)
? []
: legacyAllowedModelsValue.length === 0
? undefined
: preserveLiteralLegacyModelRefs(legacyAllowedModels ?? []);
const llm = asObjectRecord(entry.llm) ?? {};
const nextLlm = {
...llm,
...(llm.allowModelOverride === undefined ? { allowModelOverride: true } : {}),
...(llm.allowAuthProfileOverride === undefined ? { allowAuthProfileOverride: true } : {}),
...(llm.allowedCompletionModels === undefined && migratedAllowedModels !== undefined
? { allowedCompletionModels: migratedAllowedModels }
: {}),
};
const policyChanged =
llm.allowModelOverride === undefined || llm.allowAuthProfileOverride === undefined;
if (!hadLegacyAllowedModels && !policyChanged) {
return { config: cfg, changes: [] };
}
const { allowedModels: _legacyAllowedModels, ...nextPluginConfig } = pluginConfig;
const changes: string[] = [];
if (hadLegacyAllowedModels) {
changes.push(
llm.allowedCompletionModels !== undefined
? `Removed ${ENTRY_PATH}.config.allowedModels; existing ${ENTRY_PATH}.llm.allowedCompletionModels remains authoritative.`
: migratedAllowedModels !== undefined
? `Moved ${ENTRY_PATH}.config.allowedModels to ${ENTRY_PATH}.llm.allowedCompletionModels.`
: `Removed empty ${ENTRY_PATH}.config.allowedModels; unrestricted model selection remains unchanged.`,
);
}
if (policyChanged) {
changes.push(
`Enabled ${ENTRY_PATH}.llm model and auth-profile overrides to preserve shipped llm-task behavior.`,
);
}
return {
config: {
...cfg,
plugins: {
...plugins,
entries: {
...entries,
"llm-task": {
...entry,
llm: nextLlm,
config: nextPluginConfig,
},
},
} as OpenClawConfig["plugins"],
},
changes,
};
}