{stages.map((stage, i) => {
const stageProvider = providers?.find(p => p.id === stage.providerId);
- // Each stage persists its own effort, so Antigravity lists BASE models
- // with the tier picked beside them. A stage saved before the split
- // keeps its suffixed id as its own option and still runs (the server
- // splits it into `--model` + `--effort`).
- const stageModels = effortAwareModelOptions(stageProvider, stage.model);
+ const isSecurityStage = needsSecurityModelPolicy && i === 0;
+ const localBackend = localBackendForProvider(stageProvider);
+ const localModelIds = localBackend === 'ollama' ? ollama : localBackend === 'lmstudio' ? lmstudio : [];
+ // Keep the richer capability object on each local option so the shared
+ // policy does not need a second lookup. The status hook's ids are the
+ // installed-model source of truth; a provider's stale catalog is never
+ // enough to make a model eligible for a security scan.
+ const stageModels = isSecurityStage && isToolFreeLocalProvider(stageProvider)
+ ? localModelIds.map(id => ({
+ id,
+ name: id,
+ capabilities: capabilitiesByBackend?.[localBackend]?.[id],
+ }))
+ : effortAwareModelOptions(stageProvider, stage.model);
+ const selectionPolicy = isSecurityStage ? securitySelectionPolicy : undefined;
+ const stageProviderId = stage.providerId || '';
+ const stageModel = stage.model || '';
+ const stageEffort = stage.effort || '';
+
+ const updateStage = (field, value) => handleStageUpdate(i, field, value || null);
return (
@@ -62,52 +92,38 @@ export default function PipelineStageConfig({ taskType, config, providers, onUpd
→ Stage {i + 2}
)}
-
-
-
-
-
-
-
- handleStageUpdate(i, 'effort', effort || null)}
- disabled={updating}
- label="Thinking Effort"
- labelClassName="text-xs text-gray-500 block mb-1"
- className="w-full bg-port-bg border border-port-border rounded px-2 py-1.5 text-white text-xs"
- />
-
+
updateStage('providerId', providerId)}
+ onModelChange={(model) => updateStage('model', model)}
+ effort={stageEffort}
+ onEffortChange={(effort) => updateStage('effort', effort)}
+ emptyProviderOption={isSecurityStage ? 'Select local provider (required)' : 'Default (task-level)'}
+ emptyModelOption={isSecurityStage ? 'Select verified tool-free model (required)' : 'Default (task-level)'}
+ alwaysShowModel
+ selectionPolicy={selectionPolicy}
+ disabled={updating}
+ />
+ {isSecurityStage && (
+
+ {localModelsLoading
+ ? 'Loading local model capability reports…'
+ : 'Security Scan requires an explicit local model whose runtime reports no tool-calling capability. CLI/TUI agents and unknown capability states are not eligible.'}
+
+ )}
);
})}
- Each stage runs as a separate agent inside this pipeline; stages are not scheduled independently. Configure different providers per stage (e.g., Codex for review, Claude for implementation).
+
+ {needsSecurityModelPolicy
+ ? 'Security Scan runs as a direct local, tool-free preflight; later stages run as separate agents. Stages are not scheduled independently.'
+ : 'Each stage runs as a separate agent inside this pipeline; stages are not scheduled independently.'}
+ {' Configure different providers per stage (e.g., Codex for review, Claude for implementation).'}
+
);
}
diff --git a/client/src/hooks/useLocalModels.js b/client/src/hooks/useLocalModels.js
index c1832bbd43..521aae9a61 100644
--- a/client/src/hooks/useLocalModels.js
+++ b/client/src/hooks/useLocalModels.js
@@ -32,9 +32,10 @@ import { getLocalLlmStatus } from '../services/apiLocalLlm';
* runtime did not report capabilities; an absent key means that model was not
* in the local status response.
*
+ * @param {{enabled?: boolean}} [options]
* @returns {{ ollama: string[], lmstudio: string[], installed: { ollama: boolean|null, lmstudio: boolean|null }, recommendations: { ollama: object|null, lmstudio: object|null }, ctxById: Record