mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
Add cheap model profiles for local adapters (#4881)
## Thinking Path > - Paperclip is a control plane for autonomous AI companies, where adapters are the boundary between the board, agents, and execution runtimes. > - Local adapters currently expose a primary runtime configuration, but operators often need a cheaper model lane for routine or low-risk work. > - That cheap lane has to stay adapter-owned: runtime profile settings should not mutate the primary adapter config or bypass existing auth/secret mediation. > - Issue creation also needs an ergonomic way to request primary, cheap, or custom model behavior for a selected assignee. > - This pull request adds a first-class `cheap` model profile contract across adapter capabilities, heartbeat config resolution, agent configuration, and issue creation. > - The benefit is cheaper task execution can be configured and requested explicitly while preserving adapter boundaries, secret handling, and audit visibility. ## What Changed - Added adapter model-profile capability metadata and a `cheap` profile contract for supported local adapters. - Applied `runtimeConfig.modelProfiles.cheap.adapterConfig` during heartbeat config resolution, including requested/applied/fallback run metadata. - Added agent configuration UI for cheap model profile settings without writing those settings into primary `adapterConfig`. - Added New Issue assignee model lane controls for Primary / Cheap / Custom and request payload handling. - Added run ledger profile badges and Storybook stories for the new cheap-lane UI states. - Added tests for validators, heartbeat model profile application, permission/secret mediation, UI payload helpers, and run ledger rendering. - Added committed UI verification screenshots under `docs/pr-screenshots/pap-2837/`. - Addressed Greptile review feedback around cheap-profile defaults, shared profile types, and fallback test data. ## Verification Local: - `pnpm exec vitest run packages/shared/src/validators/issue.test.ts server/src/__tests__/adapter-registry.test.ts server/src/__tests__/agent-permissions-routes.test.ts server/src/__tests__/heartbeat-model-profile.test.ts ui/src/components/IssueRunLedger.test.tsx ui/src/lib/agent-config-patch.test.ts ui/src/lib/issue-assignee-overrides.test.ts ui/src/lib/new-agent-runtime-config.test.ts` — passed, 8 files / 103 tests. - `pnpm exec vitest run ui/src/lib/new-agent-runtime-config.test.ts ui/src/components/IssueRunLedger.test.tsx` — passed after Greptile/rebase follow-up, 2 files / 17 tests. - `pnpm --filter @paperclipai/ui typecheck` — passed after Greptile/rebase follow-up. - `pnpm -r typecheck` — passed. - `pnpm build` — passed. - `pnpm test:run` — did not complete successfully in this local worktree: it stopped in pre-existing `@paperclipai/adapter-utils` sandbox/SSH fixture suites outside this PR diff. Failures were 5s local timeouts plus `git init -b` unsupported by this machine's Git 2.21.0. The branch-specific targeted suites above passed. - Branch was fetched/rebased onto `public-gh/master`; `git rev-list --left-right --count public-gh/master...HEAD` reports `0 9`. Remote PR checks on latest head `e30bf399146451c86cee98ed528d51d33fa5af5a`: - `policy` — passed. - `verify` — passed. - `e2e` — passed. - `Greptile Review` — passed, confidence score 5/5; Greptile review threads resolved. - `security/snyk (cryppadotta)` — passed. Screenshots: - [New issue cheap lane desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-cheap-desktop.png) - [New issue custom lane desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-custom-desktop.png) - [New issue unsupported adapter desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/newissue-unsupported-desktop.png) - [Run ledger model profile badges desktop](https://github.com/paperclipai/paperclip/blob/PAP-2837-plan-cheap-model-for-adapters-that-can-support-it/docs/pr-screenshots/pap-2837/runledger-profile-badges-desktop.png) - Mobile variants are also in `docs/pr-screenshots/pap-2837/`. ## Risks - Medium: heartbeat config mediation now merges runtime model profiles into adapter configs, so adapter secret normalization and host-command restrictions must keep covering nested config paths. - Medium: the UI adds another issue creation choice; unsupported adapters must keep hiding the cheap lane and preserve primary behavior. - Low migration risk: no database migration is included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex coding agent using GPT-5-class reasoning with repo tool use and command execution. Exact served model/context window was not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1fe1067361
commit
a3de1d764d
60 changed files with 2216 additions and 151 deletions
|
|
@ -66,6 +66,7 @@ interface AdapterCapabilities {
|
|||
supportsSkills: boolean;
|
||||
supportsLocalAgentJwt: boolean;
|
||||
requiresMaterializedRuntimeSkills: boolean;
|
||||
supportsModelProfiles: boolean;
|
||||
}
|
||||
|
||||
interface AdapterInfo {
|
||||
|
|
@ -119,6 +120,7 @@ function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabili
|
|||
supportsSkills: Boolean(adapter.listSkills || adapter.syncSkills),
|
||||
supportsLocalAgentJwt: adapter.supportsLocalAgentJwt ?? false,
|
||||
requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false,
|
||||
supportsModelProfiles: Boolean(adapter.modelProfiles?.length || adapter.listModelProfiles),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import {
|
|||
findActiveServerAdapter,
|
||||
findServerAdapter,
|
||||
listAdapterModels,
|
||||
listAdapterModelProfiles,
|
||||
refreshAdapterModels,
|
||||
requireServerAdapter,
|
||||
} from "../adapters/index.js";
|
||||
|
|
@ -710,6 +711,99 @@ export function agentRoutes(
|
|||
return normalizedRuntimeConfig;
|
||||
}
|
||||
|
||||
function listRuntimeModelProfileAdapterConfigs(runtimeConfig: unknown): Array<{
|
||||
profileKey: string;
|
||||
profile: Record<string, unknown>;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
path: string;
|
||||
}> {
|
||||
const runtimeRecord = asRecord(runtimeConfig);
|
||||
const modelProfiles = asRecord(runtimeRecord?.modelProfiles);
|
||||
if (!modelProfiles) return [];
|
||||
|
||||
const entries: Array<{
|
||||
profileKey: string;
|
||||
profile: Record<string, unknown>;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
path: string;
|
||||
}> = [];
|
||||
for (const [profileKey, rawProfile] of Object.entries(modelProfiles)) {
|
||||
const profile = asRecord(rawProfile);
|
||||
const adapterConfig = asRecord(profile?.adapterConfig);
|
||||
if (!profile || !adapterConfig) continue;
|
||||
entries.push({
|
||||
profileKey,
|
||||
profile,
|
||||
adapterConfig,
|
||||
path: `runtimeConfig.modelProfiles.${profileKey}.adapterConfig`,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function assertNoAgentRuntimeConfigAdapterConfigMutation(req: Request, runtimeConfig: unknown) {
|
||||
for (const entry of listRuntimeModelProfileAdapterConfigs(runtimeConfig)) {
|
||||
assertNoAgentAdapterConfigMutation(req, entry.adapterConfig, entry.path);
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeMediatedAdapterConfigForPersistence(input: {
|
||||
companyId: string;
|
||||
adapterType: string | null | undefined;
|
||||
adapterConfig: Record<string, unknown>;
|
||||
constraintAdapterConfig?: Record<string, unknown>;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
input.companyId,
|
||||
input.adapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
input.companyId,
|
||||
input.adapterType,
|
||||
input.constraintAdapterConfig
|
||||
? { ...input.constraintAdapterConfig, ...normalizedAdapterConfig }
|
||||
: normalizedAdapterConfig,
|
||||
);
|
||||
return normalizedAdapterConfig;
|
||||
}
|
||||
|
||||
async function normalizeRuntimeConfigAdapterConfigsForPersistence(
|
||||
companyId: string,
|
||||
adapterType: string,
|
||||
runtimeConfig: Record<string, unknown>,
|
||||
baseAdapterConfig: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const entries = listRuntimeModelProfileAdapterConfigs(runtimeConfig);
|
||||
if (entries.length === 0) return runtimeConfig;
|
||||
const adapterModelProfiles = await listAdapterModelProfiles(adapterType);
|
||||
|
||||
const normalizedRuntimeConfig = { ...runtimeConfig };
|
||||
const modelProfiles = asRecord(runtimeConfig.modelProfiles) ?? {};
|
||||
const normalizedModelProfiles = { ...modelProfiles };
|
||||
normalizedRuntimeConfig.modelProfiles = normalizedModelProfiles;
|
||||
|
||||
for (const entry of entries) {
|
||||
const adapterProfile = adapterModelProfiles.find((profile) => profile.key === entry.profileKey);
|
||||
const adapterDefaultConfig = asRecord(adapterProfile?.adapterConfig) ?? {};
|
||||
const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({
|
||||
companyId,
|
||||
adapterType,
|
||||
adapterConfig: entry.adapterConfig,
|
||||
constraintAdapterConfig: {
|
||||
...baseAdapterConfig,
|
||||
...adapterDefaultConfig,
|
||||
},
|
||||
});
|
||||
normalizedModelProfiles[entry.profileKey] = {
|
||||
...entry.profile,
|
||||
adapterConfig: normalizedAdapterConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return normalizedRuntimeConfig;
|
||||
}
|
||||
|
||||
function generateEd25519PrivateKeyPem(): string {
|
||||
const { privateKey } = generateKeyPairSync("ed25519");
|
||||
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
||||
|
|
@ -866,15 +960,34 @@ export function agentRoutes(
|
|||
function assertNoAgentInstructionsConfigMutation(
|
||||
req: Request,
|
||||
adapterConfig: Record<string, unknown> | null | undefined,
|
||||
path = "adapterConfig",
|
||||
) {
|
||||
if (req.actor.type !== "agent" || !adapterConfig) return;
|
||||
const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS.filter((key) => adapterConfig[key] !== undefined);
|
||||
const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS
|
||||
.filter((key) => adapterConfig[key] !== undefined)
|
||||
.map((key) => `${path}.${key}`);
|
||||
if (changedSensitiveKeys.length === 0) return;
|
||||
throw forbidden(
|
||||
`Agent-authenticated callers cannot modify instructions path or bundle configuration (${changedSensitiveKeys.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
function adapterConfigTouchesInstructionsConfig(adapterConfig: Record<string, unknown>) {
|
||||
return KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) => adapterConfig[key] !== undefined);
|
||||
}
|
||||
|
||||
function assertNoAgentAdapterConfigMutation(
|
||||
req: Request,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
path = "adapterConfig",
|
||||
) {
|
||||
assertNoAgentInstructionsConfigMutation(req, adapterConfig, path);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectAgentAdapterWorkspaceCommandPaths(adapterConfig, path),
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeAgentUpdateDetails(patch: Record<string, unknown>) {
|
||||
const changedTopLevelKeys = Object.keys(patch).sort();
|
||||
const details: Record<string, unknown> = { changedTopLevelKeys };
|
||||
|
|
@ -1064,6 +1177,14 @@ export function agentRoutes(
|
|||
res.json(models);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/adapters/:type/model-profiles", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const type = assertKnownAdapterType(req.params.type as string);
|
||||
const profiles = await listAdapterModelProfiles(type);
|
||||
res.json(profiles);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
|
@ -1624,21 +1745,16 @@ export function agentRoutes(
|
|||
...hireInput
|
||||
} = req.body;
|
||||
hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType);
|
||||
const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
assertNoNewAgentLegacyPromptTemplate(
|
||||
hireInput.adapterType,
|
||||
(hireInput.adapterConfig ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectAgentAdapterWorkspaceCommandPaths(hireInput.adapterConfig),
|
||||
);
|
||||
assertNoAgentInstructionsConfigMutation(
|
||||
req,
|
||||
(hireInput.adapterConfig ?? {}) as Record<string, unknown>,
|
||||
rawHireAdapterConfig,
|
||||
);
|
||||
assertNoAgentAdapterConfigMutation(req, rawHireAdapterConfig);
|
||||
assertNoAgentRuntimeConfigAdapterConfigMutation(req, hireInput.runtimeConfig);
|
||||
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
hireInput.adapterType,
|
||||
((hireInput.adapterConfig ?? {}) as Record<string, unknown>),
|
||||
rawHireAdapterConfig,
|
||||
);
|
||||
const desiredSkillAssignment = await resolveDesiredSkillAssignment(
|
||||
companyId,
|
||||
|
|
@ -1646,20 +1762,21 @@ export function agentRoutes(
|
|||
requestedAdapterConfig,
|
||||
Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined,
|
||||
);
|
||||
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({
|
||||
companyId,
|
||||
desiredSkillAssignment.adapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
adapterType: hireInput.adapterType,
|
||||
adapterConfig: desiredSkillAssignment.adapterConfig,
|
||||
});
|
||||
const normalizedRuntimeConfig = await normalizeRuntimeConfigAdapterConfigsForPersistence(
|
||||
companyId,
|
||||
hireInput.adapterType,
|
||||
normalizeNewAgentRuntimeConfig(hireInput.runtimeConfig),
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
const normalizedHireInput = {
|
||||
...hireInput,
|
||||
adapterConfig: normalizedAdapterConfig,
|
||||
runtimeConfig: normalizeNewAgentRuntimeConfig(hireInput.runtimeConfig),
|
||||
runtimeConfig: normalizedRuntimeConfig,
|
||||
};
|
||||
|
||||
const company = await db
|
||||
|
|
@ -1814,21 +1931,16 @@ export function agentRoutes(
|
|||
...createInput
|
||||
} = req.body;
|
||||
createInput.adapterType = assertKnownAdapterType(createInput.adapterType);
|
||||
const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record<string, unknown>;
|
||||
assertNoNewAgentLegacyPromptTemplate(
|
||||
createInput.adapterType,
|
||||
(createInput.adapterConfig ?? {}) as Record<string, unknown>,
|
||||
);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectAgentAdapterWorkspaceCommandPaths(createInput.adapterConfig),
|
||||
);
|
||||
assertNoAgentInstructionsConfigMutation(
|
||||
req,
|
||||
(createInput.adapterConfig ?? {}) as Record<string, unknown>,
|
||||
rawCreateAdapterConfig,
|
||||
);
|
||||
assertNoAgentAdapterConfigMutation(req, rawCreateAdapterConfig);
|
||||
assertNoAgentRuntimeConfigAdapterConfigMutation(req, createInput.runtimeConfig);
|
||||
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
createInput.adapterType,
|
||||
((createInput.adapterConfig ?? {}) as Record<string, unknown>),
|
||||
rawCreateAdapterConfig,
|
||||
);
|
||||
const desiredSkillAssignment = await resolveDesiredSkillAssignment(
|
||||
companyId,
|
||||
|
|
@ -1836,14 +1948,15 @@ export function agentRoutes(
|
|||
requestedAdapterConfig,
|
||||
Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined,
|
||||
);
|
||||
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({
|
||||
companyId,
|
||||
desiredSkillAssignment.adapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
await assertAdapterConfigConstraints(
|
||||
adapterType: createInput.adapterType,
|
||||
adapterConfig: desiredSkillAssignment.adapterConfig,
|
||||
});
|
||||
const normalizedRuntimeConfig = await normalizeRuntimeConfigAdapterConfigsForPersistence(
|
||||
companyId,
|
||||
createInput.adapterType,
|
||||
normalizeNewAgentRuntimeConfig(createInput.runtimeConfig),
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
await assertAgentEnvironmentSelection(companyId, createInput.adapterType, createInput.defaultEnvironmentId);
|
||||
|
|
@ -1855,7 +1968,7 @@ export function agentRoutes(
|
|||
const createdAgent = await svc.create(companyId, {
|
||||
...createInput,
|
||||
adapterConfig: normalizedAdapterConfig,
|
||||
runtimeConfig: normalizeNewAgentRuntimeConfig(createInput.runtimeConfig),
|
||||
runtimeConfig: normalizedRuntimeConfig,
|
||||
status: "idle",
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
|
|
@ -2230,14 +2343,8 @@ export function agentRoutes(
|
|||
res.status(422).json({ error: "adapterConfig must be an object" });
|
||||
return;
|
||||
}
|
||||
assertNoAgentInstructionsConfigMutation(req, adapterConfig);
|
||||
assertNoAgentHostWorkspaceCommandMutation(
|
||||
req,
|
||||
collectAgentAdapterWorkspaceCommandPaths(adapterConfig),
|
||||
);
|
||||
const changingInstructionsConfig = Object.keys(adapterConfig).some((key) =>
|
||||
KNOWN_INSTRUCTIONS_BUNDLE_KEYS.includes(key as (typeof KNOWN_INSTRUCTIONS_BUNDLE_KEYS)[number]),
|
||||
);
|
||||
assertNoAgentAdapterConfigMutation(req, adapterConfig);
|
||||
const changingInstructionsConfig = adapterConfigTouchesInstructionsConfig(adapterConfig);
|
||||
if (changingInstructionsConfig) {
|
||||
await assertCanManageInstructionsPath(req, existing);
|
||||
}
|
||||
|
|
@ -2247,6 +2354,16 @@ export function agentRoutes(
|
|||
const requestedAdapterType = hasOwn(patchData, "adapterType")
|
||||
? assertKnownAdapterType(patchData.adapterType as string | null | undefined)
|
||||
: existing.adapterType;
|
||||
let requestedRuntimeConfig: Record<string, unknown> | null = null;
|
||||
if (hasOwn(patchData, "runtimeConfig")) {
|
||||
const runtimeConfig = asRecord(patchData.runtimeConfig);
|
||||
if (!runtimeConfig) {
|
||||
res.status(422).json({ error: "runtimeConfig must be an object" });
|
||||
return;
|
||||
}
|
||||
assertNoAgentRuntimeConfigAdapterConfigMutation(req, runtimeConfig);
|
||||
requestedRuntimeConfig = runtimeConfig;
|
||||
}
|
||||
const touchesAdapterConfiguration =
|
||||
hasOwn(patchData, "adapterType") ||
|
||||
hasOwn(patchData, "adapterConfig");
|
||||
|
|
@ -2292,19 +2409,20 @@ export function agentRoutes(
|
|||
requestedAdapterType,
|
||||
rawEffectiveAdapterConfig,
|
||||
);
|
||||
const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
effectiveAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
const normalizedEffectiveAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({
|
||||
companyId: existing.companyId,
|
||||
adapterType: requestedAdapterType,
|
||||
adapterConfig: effectiveAdapterConfig,
|
||||
});
|
||||
patchData.adapterConfig = syncInstructionsBundleConfigFromFilePath(existing, normalizedEffectiveAdapterConfig);
|
||||
}
|
||||
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
|
||||
const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {};
|
||||
await assertAdapterConfigConstraints(
|
||||
if (requestedRuntimeConfig) {
|
||||
const baseAdapterConfig = asRecord(patchData.adapterConfig) ?? asRecord(existing.adapterConfig) ?? {};
|
||||
patchData.runtimeConfig = await normalizeRuntimeConfigAdapterConfigsForPersistence(
|
||||
existing.companyId,
|
||||
requestedAdapterType,
|
||||
effectiveAdapterConfig,
|
||||
requestedRuntimeConfig,
|
||||
baseAdapterConfig,
|
||||
);
|
||||
}
|
||||
if (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")) {
|
||||
|
|
|
|||
|
|
@ -47,11 +47,14 @@ export function assertNoAgentHostWorkspaceCommandMutation(req: Request, paths: s
|
|||
);
|
||||
}
|
||||
|
||||
export function collectAgentAdapterWorkspaceCommandPaths(adapterConfig: unknown): string[] {
|
||||
export function collectAgentAdapterWorkspaceCommandPaths(
|
||||
adapterConfig: unknown,
|
||||
prefix = "adapterConfig",
|
||||
): string[] {
|
||||
if (!isRecord(adapterConfig)) return [];
|
||||
return collectWorkspaceStrategyCommandPaths(
|
||||
adapterConfig.workspaceStrategy,
|
||||
"adapterConfig.workspaceStrategy",
|
||||
`${prefix}.workspaceStrategy`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue